Skip to content

feat(cli, plugin)!: Route plugins by manifest - #1216

Merged
JeanMertz merged 22 commits into
mainfrom
rfd-072
Oct 2, 2026
Merged

JeanMertz merged 22 commits into
mainfrom
rfd-072

Conversation

@JeanMertz

@JeanMertz JeanMertz commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Command plugins now declare where they attach to jp in a manifest, a
line of JSON embedded in the binary that JP reads without running it.
A binary named jp-webui whose manifest claims ["serve", "web"]
answers jp serve web, and jp -h lists every plugin's command and
description without spawning any of them. When several binaries claim
one command, the longest claim wins, a third-party binary replaces an
official command, and claimants that cannot be told apart are an error
naming both.

No plugin runs before it is admitted, jp <plugin> -h included. Every
plugin defaults to run = "ask": an official binary matching its
registry checksum, or one recorded in the approval store, runs without
a prompt. Anything else prompts, showing the binary's path, whether it
changed since it was approved, and whether it is a third-party plugin
replacing an official command. Without a terminal, JP refuses and
prints the jp plugin approve <path> command that would admit it.

Official plugins install the first time their command is typed;
third-party plugins install only through jp plugin install, which
asks first and installs what the plugin requires. Running an installed
plugin never reaches the network, and jp plugin update updates the
official plugins JP installed. The new jp plugin approve, revoke,
and uninstall commands manage approvals and binaries, and every
jp plugin command works outside a workspace. A plugin's print
output follows --quiet and --format json, and stopping a plugin
kills everything it started.

BREAKING CHANGE: plugins.auto_install and
plugins.command.<name>.install are removed

Installing is no longer configured. A config file or --cfg argument
that still sets either key fails to load; delete the key. Stored
conversations are unaffected: the keys are dropped when a conversation
loads, and the plugin settings beside them are kept. A plugin binary
without a manifest no longer claims a command: rebuild it against this
jp. jp plugin approve <path> records the claims of a binary whose
manifest cannot be read, such as one compressed with UPX, but only for
a binary built against this jp. A plugin that ran without asking
because it was installed now needs an approval, or
plugins.command.<name>.run = "allow".

DEPRECATED: the unattended run mode is renamed to allow

Tool (run, result, format), label, and plugin settings that skip
the prompt now spell it allow. unattended still works in config
files, --cfg, and stored conversations, and logs a warning; replace
it with allow.

@JeanMertz
JeanMertz force-pushed the rfd-072 branch 2 times, most recently from c198cc3 to 62ee739 Compare October 1, 2026 12:09
RFD 072 routed a command by the `command` field of a plugin's `describe`
answer, but also required admission before every spawn, `describe`
included. On a fresh install the two could not both hold: to learn
which binary answers `jp serve web`, JP had to run binaries nobody had
approved.

Each plugin binary now carries a manifest, one line of JSON (`jp-plugin/v1
{...}`) that JP reads from the file without running it. It holds only
what JP needs before it decides to run a plugin: the protocol version,
a one-line description, and the claimed command path. The `describe`
answer repeats those fields and adds `help`, and the two must agree.
The file name keeps naming the plugin's config and approval, and two
binaries with one name conflict.

RFD 077 changes to match. `unattended` becomes `allow`, and installing
is no longer configured: official plugins install on first use, and
third-party plugins only through `jp plugin install` or by putting a
binary on `$PATH`. Every plugin defaults to `run = "ask"`, answered
without a prompt by a registry-matching official binary or by an entry
in a user-local approval store that never reaches config or a
conversation. Running an installed plugin makes no network request;
`jp plugin update` updates official plugins. RFD 114 reads a plugin's
workspace scope from its manifest, so admission happens once, under the
configuration that governs the run.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
A config enum variant can now list retired spellings with
`#[variant(deprecated_aliases("unattended"))]`. `FromStr` still parses
them to the variant, and logs a warning naming the replacement, once
per spelling per process so a value read from many stored documents
does not repeat it.

Unlike `aliases`, deprecated spellings stay out of the schema and
`variants()`, so nothing offers a spelling that is on its way out, and
a parsed value always displays and serializes under its canonical
name.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
Tool and label settings that skip the confirmation prompt now spell it
`allow`: a tool's `run`, `result`, and `format`, and a label's `run`.
`unattended` described how the tool runs, when the setting is really
the decision not to ask; a tool at `unattended` could still ask the user
a question through an inquiry.

```toml
[conversation.tools.'*']
run = "allow"
```

`unattended` still works everywhere it did, in config files, `--cfg`,
and conversations stored before the rename, and logs a deprecation
warning once per run. Config and conversations are written back as
`allow`, and the schema and generated config offer only `allow`.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
`--quiet` silences the chrome channel on stderr, and every stderr write
went through it, so there was no way to report an error while chrome
was off. `Printer::error_println` writes a line to stderr whether or not
chrome is silenced, wrapped as a `{"message": ...}` record under a JSON
output format like `eprintln`. `error_println_raw` does the same for an
error that is already a JSON record of its own.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
A command plugin now says where it attaches to `jp` in a manifest, one
line of JSON embedded in its binary, and JP reads it from the file
without running the plugin. A binary named `jp-webui` whose manifest
claims `["serve", "web"]` answers `jp serve web`, and `jp -h` lists
every plugin's command and description without spawning any of them.
When several binaries claim one command, the longest claim wins, a
third-party binary replaces an official command, and two claimants
that cannot be told apart are an error naming both.

Every plugin now defaults to `run = "ask"`, and nothing runs before it
is admitted, `describe` for `jp <plugin> -h` included. An official
binary matching its registry checksum, or one recorded in the approval
store, runs without a prompt. The prompt shows the binary's path, says
when it changed or when another file is the approved one, and marks a
third-party binary that replaces an official command. Without a
terminal JP refuses and prints the `jp plugin approve <path>` command
that would admit it.

Official plugins install the first time their command is typed;
third-party plugins install only through `jp plugin install`, which
asks first and installs what the plugin requires. Running an installed
plugin never reaches the network: `jp plugin update` updates the
official plugins JP installed, and says why it leaves any other alone.
The new `jp plugin approve`, `revoke`, and `uninstall` manage approvals
and installed binaries, and every `jp plugin` command works outside a
workspace. A plugin's `print` output now follows `--quiet` and
`--format json`, and stopping a plugin kills everything it started.

BREAKING CHANGE: `plugins.auto_install` and
`plugins.command.<name>.install` are removed

Installing is no longer configured, and a config file that still sets
either key fails to load; delete the key. A plugin binary without a
manifest no longer claims a command: rebuild it against this `jp`, or
record its claims with `jp plugin approve <path>`. A plugin that ran
without asking because it was installed now needs an approval, or
`plugins.command.<name>.run = "allow"`.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
Ticket 0sz2taf is fixed: `jp -h` no longer spawns plugins, and
`jp <plugin> -h` is admitted before `describe` runs. A test proves an
unapproved binary is never run. The RFD 072 tracking ticket records that
every phase is implemented, along with RFD 077 Phases 3 to 5.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
`plugins.auto_install` and `plugins.command.<name>.install` were
removed, and conversations stored before that still carry them in their
base config and config deltas. The test proves such a conversation still
loads through the config recovery path, and that a `run = "deny"` stored
beside a dropped `install` key survives rather than being lost with its
entry.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
`jp plugin approve` now names a plugin by the file it is given, the way
discovery names it. A link such as `/usr/local/bin/jp-titles` into
`/opt/titles/jp-titles-1.2` is the plugin `titles`: its deny setting
applies, and its approval is stored where dispatch looks for it. Approve
also refuses a binary whose contents do not match a pinned checksum
before running it for `describe`, and hashes the binary once, before it
runs, so the approval covers the contents that were checked.

Plugin paths found on a relative `$PATH` entry are made absolute, so a
plugin run from another workspace's directory (`jp -w …`) is the same
file admission checked. Asking a plugin to describe itself now owns its
process tree the way a run does: a plugin that keeps running after
answering no longer blocks `jp <plugin> -h` or `jp plugin approve`, and
a worker it started does not outlive it.

An approval store written before approvals carried a timestamp still
loads, instead of being read as empty and overwritten on the next
approval. The discovery tests use platform file names, so they pass on
Windows.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
Pressing Ctrl-C while a plugin is starting up for `jp plugin approve` or
`jp <plugin> -h` now stops the plugin and everything it started, and
`jp` exits as interrupted. Since describing a plugin moved it into a
process group of its own, the terminal's Ctrl-C no longer reached it,
and `jp` exited without the cleanup that would have: the plugin and its
workers kept running, holding ports or files, with nothing to say so.

A command published under a command group after the registry cache was
written, such as `jp serve http-api`, now refreshes a stale cache like
any other command nothing on this machine claims. Before, the cached
group answered for it and reported that no plugin provides it, however
old the cache was. `jp serve` and `jp serve -h` still answer from the
cache.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
`CommandPluginConfig::options` became a `MergeableMap` on `main` (#1218),
so the test for stored conversations carrying the removed plugin install
keys no longer compiled after the rebase. It now builds an empty map, and
its imports move to the top of the file.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
A plugin that exits on its own no longer leaves the workers it started
running, and the kill can no longer reach an unrelated process. On Unix,
JP now waits for the plugin to exit without reaping it, kills its
process group, and only then reaps it: while the plugin is unreaped its
pid, and with it the group id, cannot be reused. Before, JP reaped the
plugin first, leaving a short window in which the group id could belong
to someone else.

On Windows, asking a plugin to describe itself, for `jp <plugin> -h` and
`jp plugin approve`, now starts it suspended and resumes it only once it
is in its job object, as a run already does. A worker it started right
away could otherwise escape the job, and survive the plugin.

Closes: T-0vmsq99
Signed-off-by: Jean Mertz <git@jeanmertz.com>
The doc comment on each plugin's public `MANIFEST` linked to its private
`REQUIRED_PROTOCOL`, which rustdoc cannot resolve from a public item. The
reference is now a plain code span, as the formatter writes it.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
Signed-off-by: Jean Mertz <git@jeanmertz.com>
`Utc`, `ApprovedPlugin`, and `Globals` are only used by dispatch tests
that run on Unix, so on Windows they were unused imports, which CI
treats as errors. They are now behind `#[cfg(unix)]`, like the tests
that use them.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
A plugin that exits before reading `init` makes the host's write fail
with a broken pipe. That error returned before any cleanup ran, so a
worker the plugin had started kept running after `jp` reported the
failure. The `init` write is now part of the run's result, and the
plugin's process tree is stopped on that path like on every other.

`jp plugin approve` stopped the plugin it was asking to describe on
Ctrl-C only. A SIGTERM, from `timeout` or a CI runner cancelling a
step, ended `jp` at once and left the plugin and its workers running in
their own process group. It now listens for the same signals the
signal router treats as a graceful shutdown: Ctrl-C and SIGTERM, or
Ctrl-C and Ctrl-Break on Windows.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
A test's doc comment and the tool progress window fixture still wrote
`run = "unattended"`. Both kept working, since `unattended` is a
deprecated spelling of `allow`, but loading the fixture logged the
deprecation warning on every manual run.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
RFD 072 is marked Implemented, with every phase of its plan done.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
`plugins.shutdown_timeout_secs` was never read: a plugin that ignored
`Shutdown` was always killed after five seconds, whatever the setting
said. The setting now decides how long JP waits after a Ctrl-C or
SIGTERM before it kills the plugin and everything it started. A plugin
stopped because its run failed gets the same grace, capped at a second.

A checksum pinned with `algorithm = "sha1"` refused every binary,
because JP always compared the pin with the binary's SHA-256. Admission
and `jp plugin approve` now hash the binary with the algorithm the pin
names, so a correct SHA-1 pin admits the binary, and a mismatch reports
the binary's SHA-1 as the actual value.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
RFD 077 described `options` as a single JSON value sent in `init`'s
`config` field. It is a table that merges key by key across config
layers, sent in `init.options`. Its example, and the one in the config
reference, used `web.port` and `web.host`, which `serve-web` never
read: it reads `bind` and `port`.

The shutdown grace period kills the plugin's whole process tree, not
only the process JP spawned, and the RFD now says so.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
Signed-off-by: Jean Mertz <git@jeanmertz.com>
`jp serve metrics`, where `serve` is an official command group and
`metrics` a third-party plugin the registry lists, reported that no
plugin provides the command. The group's prefix matched, so the
command never reached the hint an unknown command at the root gets.
It now names the plugin and the command that installs it:

    error: unrecognized subcommand 'metrics'

      `jp serve metrics` is provided by the third-party plugin
      `metrics` (https://…). Install it with `jp plugin install metrics`.

The registry entry still only feeds the error: the command routes to
the group, so a catalog entry never decides which plugin runs.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
RFD 072 showed `jp conversation export` and `jp conversation stats` as
plugin commands, and its first routing rule said an unknown child of an
extensible built-in group could still resolve to a plugin. No built-in
group accepts unknown subcommands, so clap rejects `export` before any
plugin routing runs.

The RFD now says plugins attach at the root or under a group a plugin
or the registry provides, drops the two examples and the clause, and
lists plugin children of built-in groups under Non-Goals for a later
RFD.

Signed-off-by: Jean Mertz <git@jeanmertz.com>
@JeanMertz
JeanMertz merged commit eb64cc8 into main Oct 2, 2026
22 checks passed
@JeanMertz
JeanMertz deleted the rfd-072 branch October 2, 2026 13:45
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.

1 participant