0047: Plugins are managed from the web app, and adding one shows what it installs before it is confirmed¶
Context and Problem Statement¶
The maintainer wants to install, list, enable, disable and remove plugins in the web app, as a desktop chat app's
"Plugins" settings do: a "Yours" tab, a "Discover" tab with search, "Add", and a menu per plugin. Today hz system
plugin add takes "anything uv installs" (a name, a git URL, a local path, a package) and installs it at once, and its
add and remove operations are endpoints of every head, the unauthenticated REST head included
(ADR 0019). Adding a plugin runs code with the person's rights, so a click in a browser
must not be enough to install something nobody looked at, and no page that can reach the REST port may install
anything. A running hz ui also loaded its plugins at start, so it can't see one added under it. How are plugin
changes exposed, what does add accept, and how does the running web app pick a change up?
Decision Drivers¶
- Installing a plugin is a code-execution decision: the person sees the source, version and publisher first, and confirms; what they saw is what gets installed.
- The token-protected web head and the person's own CLI and MCP may change plugins; the unauthenticated REST head never may (ADR 0039, security model).
- Heads are views (ADR 0003): the confirmation is a parameter of the operation, the same in every head, not a dialog only the UI has.
- Plugins stay
--withrequirements of the uv tool environment (ADR 0006), and private GitHub repositories work with the person's gh login, without a token passing through Heinzelmännchen (ADR 0035).
Considered Options¶
- One
addthat shows withoutconfirmand installs the pinned requirement with it; PyPI and https git only; local paths through a CLI-onlydevelop; changes on CLI, MCP and the web head only; a history file; enable and disable through[hz] disabled_modules; a restart that keeps the port and token (chosen) - A separate preview operation and
addunchanged: two ways to learn the same thing, andaddwould still install unseen code from every head. confirmonly on the web head: an operation can't know its head (ADR 0003); an agent over MCP would install without a look.- Reload the kernel's module discovery in the running process:
uv tool install --forcereplaces the very environment the process runs from, and modules already imported stay loaded; a half-old process is worse than a short restart. - Keep local paths in
add: a web page or agent could name any directory; developing a plugin is a person at a terminal, so it is a CLI-only operation.
Decision Outcome¶
Chosen option: one add that shows first and installs on confirm, a narrow set of sources, changes only behind a
person or the session token, and an in-place restart of hz ui, because it keeps one operation per action in every
head and makes the dangerous step explicit and checkable. Proposed for the maintainer, 2026-10-24.
- Which heads.
hz system plugin add,remove,enable,disableandhistorydeclareheads={"cli", "mcp-stdio", "web"};listandsearchstay on every head;developandneware CLI only. This removesaddandremovefrom the REST head, which had them since ADR 0006.mcp-stdiois a newheadsentry: MCP over stdio only, because the client started that server as the person, while MCP over streamable HTTP has no authentication either (headmcp-httpinheads/_exposure.py;mcpinheadsstill covers both transports). - Show, then confirm.
add SPECwithoutconfirminstalls nothing: it reads PyPI's JSON API or the GitHub API (throughgh apiwhen gh is installed, so private repositories work; otherwise over https as ADR 0033 does for public ones) and answers aPluginCandidate: the distribution, version, summary, publisher, source, whether the repository is private, andrequirement, pinned to the release (heinzel-x==1.2.0) or the git commit (heinzel-x @ git+https://…@<sha>) it read.add SPEC --confirminstalls that requirement; the web app sends backrequirementasspec, so the dialog's version is the one installed. Unknown packages, repositories and refs are refused before anything runs. A host other than GitHub is installed as given, with a warning that nothing could be read. - Which sources. First-party names (the plugins in
plugins/, ADR 0017, and the reference companyacme, ADR 0040), PyPI packages namedheinzel-<name>(ADR 0025), and git repositories over https. Refused: local paths andfile:URLs (hz system plugin develop PATHinstead), ssh, plain http, archives and wheels, URLs with a user name or token, queries, dot segments or escapes, refs or subdirectories with shell or path syntax, extras and markers, and anything that starts like an option. The name a repository'spyproject.tomlgives must be a valid distribution name, notheinzelitself, the one asked for if one was, and not a first-party plugin's (which would silently replace it); metadata text is shown without control characters. - Private repositories. uv's git gets
credential.https://github.com.helper = !gh auth git-credentialthroughGIT_CONFIG_*variables for the reinstall, appended to any the person set: git asks gh, and the token never passes through Heinzelmännchen or lands in a URL, the receipt or the history. - What was installed is recorded. Every carried-out change appends a line to
<data_dir>/plugins.jsonl(time, action, distribution, exact requirement, source, version, publisher);hz system plugin historylists it. One change runs at a time. - Enable and disable add or remove the plugin's top-level modules in
[hz] disabled_modulesof the user file, the kernel's existing switch; the kernel keeps the blocked records (Kernel.blocked), solistshows a disabled plugin withenabled: false. A plugin disabled by another file orHZ_DISABLED_MODULESis not silently "enabled". listsays what a plugin brings and where it came from: summary,source_kindand a browsersource_url(from PEP 610'sdirect_url.jsonor its Project-URL metadata, https only, credentials stripped), the git commit, whether it is first-party, enabled or failing to load, its layers, adapters and playbook modules.- Restart instead of reload. Every result of a change says
restart_required: true: a newhzcommand sees it at once, a runninghz uior MCP server doesn't. The web head addsPOST /api/restart(behind the token, like every/apicall):hz uistops serving and executeshzagain with its arguments, handing the new process a duplicate of its listening socket and its token (HEINZEL_UI_RESUME, read and removed at start; a handed-over socket that isn't a TCP listener on 127.0.0.1 starts a fresh session instead), so the open tab keeps its URL and token, and requests made meanwhile wait in the socket's backlog. A server that isn'thz ui(make web-dev) answers 400. This amends ADR 0039's "the API is the operations": the restart is the head's own endpoint, because only the head knows how it runs.
Consequences¶
- Good: the web app gets "Yours" (
list), "Discover" (search), "Add" (addthenadd --confirm), and the per-plugin menu (disable,enable,remove) from operations the CLI and MCP share; the dialog shows exactly what installs. - Good: an unauthenticated local page can no longer install or remove plugins through the REST head.
- Good: the history answers "what did I install, from where, and when".
- Bad:
hz system plugin add githubnow shows and asks for--confirm; documentation and scripts that install say--confirm, andhz system plugin add .becamehz system plugin develop .. - Bad: git sources are pinned to a commit in the uv receipt; adding the plugin again moves it, an upgrade of the core doesn't.
- Bad: a long install holds the HTTP request open for minutes (uv's own timeout is 15); a job with progress is later work.
- Bad: the token of a restarted
hz uipasses through the process environment, which other processes of the same user can read; the same user can read the browser'ssessionStoragetoo. - Bad: one change at a time holds within a process; a CLI
addracing the web app's is not locked out yet. - Neutral: themes that plugins ship are listed once their record lands;
listis the place.
Confirmation¶
tests/unit/modules/plugin_sources_test.py (accepted and refused sources, gh and https readers, credential variables,
browser URLs), tests/unit/modules/plugin_service_test.py (pinning to the PyPI release and the git commit, no install
without confirm, the history, one change at a time, develop, what list tells),
tests/unit/kernel/kernel_test.py (Kernel.blocked), and tests/integration/plugin_web_test.py: the web head exposes
the changes and the REST head doesn't, the token is required, confirm is required, bad sources get 400, disabling and
enabling write the user file, and a restart hands the port and token to the process it becomes.
More Information¶
- Asked for by the maintainer, 2026-10-24 ("install, list, enable/disable and remove plugins from within the web UI"). The React screens are built separately on these endpoints.
- Open: whether
hz system serve mcpshould restart itself the same way (stdio clients restart servers themselves); whether a source allowlist per company (only its private repository and first-party plugins) should be a setting.