Skip to content

io-cli 0.29.0 - #72

Merged
aakashpawar1999 merged 13 commits into
mainfrom
develop
Aug 29, 2026
Merged

aakashpawar1999 merged 13 commits into
mainfrom
develop

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

Release PR for 0.29.0 — plugin marketplaces, and what a bundle is allowed to do shown before it is.

Everything in this PR reached develop through #71, whose checks passed 8/8. git diff --quiet feat/0.29.0 develop is clean, so this is the tree the gates ran on.

What ships

Marketplaces. /plugin marketplace add|list|remove, and the same verbs under io plugin marketplace … through one parse. A marketplace is a git repository you name, cloned shallow into ~/.io-cli/marketplaces; a bundle inside one is any directory carrying a plugin.toml. No index file, no registry, no ranking.

Install by name. /plugin add <name>[@<marketplace>], install as the same verb, /plugin search across every marketplace added. A bare name two marketplaces carry is refused rather than resolved.

Disclosure before load. The install writes the entry enabled = false, re-discovers, and shows what io-harness actually parsed off Plugins::disabled() — in the namespaced names you will meet in a trace. Consent flips one key; declining leaves the bundle declared, off, and listed. Hooks are named from the manifest, because io-harness publishes no accessor for them.

The io-harness pin, 0.69 → 0.70.0, with three consequences followed: a run that failed its verification exits 6 again and is retried; /plugin and /mcp both report a thing declared enabled = false; and the git refusal 0.25.0 was built around is documented as fixed upstream rather than as current behaviour.

Gates

  • cargo test --no-fail-fast — 99 binaries, 1,567 passed, 0 failed, 36 ignored
  • Live suite — 36 passed, 0 failed, 300.68s against a real endpoint
  • clippy clean, fmt --check exit 0, nightly rustdoc exit 0 with zero warnings
  • 16 sabotage arms across all 13 functional criteria
  • Adversarial diff review — 2 HIGH, 6 MEDIUM, 6 LOW, all addressed

The record is sealed and pinned: sha256:87db86b435b21cac6516a773846e731acbe714b1525d30e2fbb598b8028595ba, ultraship validate exit 0 over 84 checks. .ultraship/ is gitignored whole, so the record lives on disk and never in the history — its absence from this diff is by design, not an omission.

0.70.0 adds a required `enabled` field to the public `McpServer` struct, so
this is a compiling break rather than a mechanical bump. Three sites take it:
`manage::mcp_add` now binds one `McpServer::stdio("", "")` and reads both
`timeout_secs` and `enabled` off it, keeping the file's existing rule that a
default is asked of the harness rather than written here as a literal; the two
round-trip fixtures in `tests/servers.rs` name `true`, which is io-harness's
own default and therefore what an entry written without the key reads back as.

The flag has no keystroke in this release. `[[mcp]]` and `[[plugin]]` gaining
`enabled` is what 0.30.0's roadmap entry was blocked on, and it stays there.
… retry firing

0.70.0 closes this crate's own io-harness#212. A run that reached its step cap
having failed its criterion was `RunOutcome::StepCapReached` and is now
`RunOutcome::VerificationFailed { steps }`; `StepCapReached` narrows to mean
only that nothing judged the work.

`RunOutcome` is `#[non_exhaustive]`, so none of this broke the build. Three
things were quietly wrong instead:

- `exec::code` fell to `_ => UNFINISHED`, so a failed-gate run exited 5 where
  it had exited 3, and where it should exit 6. It now answers `UNVERIFIED`
  directly. Mapping it to `CEILING` was the other candidate and is wrong: it
  would move exactly the runs exit 6 was invented for from 6 to 3 on a pin
  bump, changing a published exit table underneath somebody's CI.
- `exec::describe` fell to its catch-all and printed "the run ended in a way
  this build does not have a name for", losing the step count.
- **The gate retry stopped firing for the ending it was built for.** The driver
  admits `OK | CEILING`, and the comment above it says excluding ceilings would
  mean "the commonest failing-gate ending was never judged and never retried".
  0.70.0 moved that ending out of `CEILING`. `UNVERIFIED` is now admitted
  beside them.

The `UNVERIFIED` doc claimed io-harness had no variant for a criterion that
answered no. It has one. `verified_code` stays as the second route rather than
the only one — a gate can fail on a run that ended some other way, and only the
recorded `GateOutcome` sees that.

Gated by a driver-text test, because nothing under `tests/` links `src/main.rs`.
…0.0 pin hid

io-harness 0.70.0 splits plugins into three buckets. `Plugins::iter`, `get`,
`names`, `len` and `is_empty` all read `loaded` only — its own rustdoc says so —
and `pluginview::view` read `iter()` and `dropped()`. So from the moment the pin
landed, a bundle declared `enabled = false` appeared in neither `View::plugins`
nor `View::refused`: `/plugin` showed nothing, `io plugin list` printed nothing,
and `View::is_empty` answered true for a configuration declaring several, at
which point the driver said "no capability bundles are declared yet" over a file
declaring three. That is the failure this module exists to end, inverted.

`view` now chains `plugins.disabled()` after `plugins.iter()`, with
`Listed::enabled` carrying the state. The flag is an argument rather than a field
read off the bundle, because the only public signal is which bucket it came back
on — `Declaration::enabled` is `pub(crate)`.

`DISABLED_MARK` is `-`, beside `+` and `!`; one ASCII character, so it survives
the ASCII glyph set. `rows` keeps two ranges and three marks: a third range would
put a third arm into every index calculation on this surface, which is the shape
that shipped a wrong delete in 0.20.0.

`bundle_skills` gains `.filter(|listed| listed.enabled)`, and that filter is the
point rather than a detail. It reads off `pluginview::view`, so widening `view`
without it would have put a switched-off bundle's skills into the palette while
`TaskContract::discover_skills` never folded them in — the run then fails on a
skill name the surface promised.

`io plugin list` gains a third column, appended rather than inserted so a script
reading the two it has always printed keeps working.

Two hazards recorded rather than fixed: `Listed::id` is no longer unique across
the list, because io-harness reserves no id for a disabled bundle, and nothing in
io-cli keys on it; and a disabled row's detail is longer than a loaded one's,
an overflow that already existed for loaded rows.
…n relax it

io-harness owns git and publishes no way to run a subcommand of it: `Git`,
`Git::run` and `GitCmd` are `pub(crate)`, and its own tests assert no argv it
builds can carry `clone`, `fetch` or `push`. That absence is deliberate there, so
a marketplace cannot be brought down through the dependency. The only alternative
to spawning `git` here is an HTTP client with a TLS stack under it and a second
network path beside io-harness's, which is a bigger hole in N1 than one more
permitted path.

`src/fetch.rs` carries the crate's second and last spawn. The program is the
constant `PROGRAM = "git"`; `argv` returns a `Vec<OsString>` of separate
elements with nothing interpolated, so a repository name cannot become part of a
flag. An existing destination is answered before anything is spawned, the clone
assembles at a staging path and is moved into place, and the staging path is
cleared before and after — so the only thing that can survive the call is a
complete tree at the destination. stdin is null and `GIT_TERMINAL_PROMPT` is `0`,
so a private URL fails with a sentence instead of hanging the viewport on a
credential prompt. Both paths are the caller's, which is what lets the tests
drive every ending against a local repository.

`tests/dependencies.rs` is amended, not relaxed. `spawning_module` becomes
`spawning_modules`, a set of two exact paths, sorted, compared as a set. The
rustdoc names the sabotage it must survive: a gate written as
`path.contains("fetch")` would admit `src/fetch/anything.rs` and go on passing
while a third module spawned, because a substring match is a permission list that
widens itself. The aliased spellings stay banned everywhere, `src/fetch.rs` names
none of the run-event types, and `src/shell.rs` is still reachable from
`src/main.rs` alone.

`~/.io-cli/marketplaces` is created beside `skills/`, best-effort and 0700, the
way that one is.

Nothing calls this module yet — `/plugin marketplace add` wires it next.
…ims it falsified

**The sabotage had nowhere to run.** F11's second arm is "widen the permitted set
to a substring match rather than a path set". A widening like that makes the gate
more permissive, so nothing fails — the gate goes vacuous without going red, and
the call site cannot observe it because the call site's own list is the thing
being widened. The rustdoc named the hazard and no test held it.

The comparison is now `only_the_permitted_spawn`, and
`f11_the_permitted_spawn_set_is_exact_paths_and_never_a_substring` hands it four
near-miss paths — sharing a stem, sharing a prefix, sitting under a directory
named for a permitted stem, and the same shape against the other module — plus a
control asserting the permitted set admits itself, so a predicate answering false
for everything cannot satisfy it. Rewrite the comparison as any `contains`,
`starts_with` or stem match and the test goes red naming the file it admitted.

This product shipped a gate that could not fail in 0.25.0 and again in 0.27.0.
0.28.0 recorded the rule both broke: enumerate the arms and check each has a site
before trusting the set.

Two doc claims went stale in the release that amended the gate, which is when
they get fixed rather than later. `src/repo.rs` said the spawn gate permits
`src/shell.rs` alone and was "unamended by this release"; `src/lock.rs` said a
spawn is permitted only in one module. Neither broke a gate, because neither
spells the literal that gate matches on.

`cargo fmt --all` over the wave — subagent-written code is not fmt-clean and
`cargo fmt --check` is a CI gate.
A plugin can now come from somewhere other than a directory the operator already
had. `/plugin marketplace add <owner>/<repo>` and `io plugin marketplace add …`
reach one parse, one judged `Named`, and one clone under
`~/.io-cli/marketplaces/<owner>/<repo>`. A bundle inside it is any directory
carrying a `plugin.toml`, so there is no index to write and none to disagree with
the directories it describes.

No new command. `Configure` stands at nine of ten and is the product's last free
slot; marketplace is verbs inside `/plugin`, which is the shape 0.28.0
established. `COMMANDS.len()` is still 36 and the occupancy is still 7/9/10/10.

`marketplace::declared` reads `name` and `description` through `edit::value_at`
and unquotes them the way `pluginview::declared_at` does, so `src/edit.rs` is
still the only file in this crate permitted to parse TOML. A directory whose
manifest does not name itself is still a bundle: it is listed under its directory
name and says the manifest did not name it, rather than being dropped silently.

All three verbs `plan` to `Ok(None)`. An add or a remove is a change to the disk
and not to any file io-harness layers, so there is no scope to choose and no
entry to spell. An empty `Plan` would make `configure::write` create a file and
report a write that never happened, and a second member on `Plan` would be the
second write path that module forbids. That is also F3: no edit is ever built for
a removal, so a removal cannot take a declaration with it.

`marketplace::remove` has three doors and `add` has two, which is not an
oversight. The panel's "add a marketplace" row prefills the composer, because a
name is free text with nothing to choose from, so it arrives back through the
typed door; the panel's remove acts on a marketplace the operator picked, after a
`store::LEAVE_IT` confirmation, so it is a real third door. The gate asserting
this was written expecting symmetry and said two for both — corrected, with the
doors named, rather than the count merely raised.

Every public item in `src/fetch.rs` now has a production call site. It had none
when it landed, which is the shape this product has shipped five times.
Captured at v0.28.0, with the card's model and the footer's model agreeing.

The alt text gained the two things the picture now shows and did not before — the
tagline beside the version, and the muted line saying this is a new conversation
with the last one still in /resume. Its account of the footer is unchanged and
was already right: state, model and clock on the first row, the keys and the
posture on the second. Alt text is the only form of this picture a screen reader
gets, so it is checked against the image rather than carried forward.
…places

`/plugin add <name>`, `<name>@<marketplace>`, and `install` as the same verb
under another word. `/plugin search <text>` reads names and descriptions across
every added marketplace and prints one finished line per hit, whose first field
is exactly what `plugin add` takes.

**A word is a path if it resolves to a directory carrying a `plugin.toml`, and a
name otherwise.** The alternative is reading the shape of the word — a slash, a
leading dot, an extension — and that makes one word mean different things
depending on the operator's working directory: `plugin add rust-review` would be
a directory on a machine that has one and a marketplace bundle on a machine that
does not, with no way to tell which happened. Asking the disk cannot drift, it
can never make a real relative path unusable, and the check is
`pluginview::refusal`, which this verb already had to pass before writing
anything, so it is not a second reader. When both readings fail the two refusals
are joined rather than chosen between, because io-cli does not know which was
meant and reporting one half hides the other.

**A bare name two marketplaces carry is refused, naming every qualified
spelling.** Resolving to the first match installs code the operator did not
choose, and `shared@ultraship` resolving to the second marketplace is asserted
for the same reason — a first-match resolver gets that wrong too.

The install ends in the existing `pluginview::add` → `configure::write`. No
second writer; `manage::plan` still returns `Ok(None)` for every read.

`verbs("plugin")` was updated in the same edit as the parse, and four rows were
added to the refusal table that holds the two in step.

`src/cli.rs`'s `io --help` text described three verbs and now describes what the
subcommand actually does — flagged rather than left, because that string is the
first thing a shell user reads.
… to do it

A bundle from a marketplace is code from a stranger contributing to six
subsystems at once. Until this release every bundle came from a directory the
operator had read themselves.

io-harness 0.70.0 publishes no loader that takes a directory — `load_one` is
private and `Plugins::load` is `pub(crate)` — so the disclosure is not a preview
io-cli parses. The entry is written `enabled = false`, the configuration is
re-discovered, and io-harness reads, parses, validates and trust-checks the
bundle for real, handing it back through `Plugins::disabled()` as a `Plugin` that
contributes nothing. What the operator sees is what the harness parsed:
namespaced agent names, the MCP servers, the policy layers, the skills and
template directories. A bundle the harness refuses is refused at that point,
before consent, in its own words.

Consent flips one key through `Edit::set("plugin[N].enabled", "true")` — the
indexed path `split_path` already peels — so the file is byte-identical but for
`false` becoming `true`. Declining leaves the bundle declared, off, and listed
under its own mark.

**Hooks are read from the manifest, and only to be shown.** There is no
`Plugin::hooks()` and `Hook` is `pub(crate)` (io-harness#223), so
`contributions()` answers the bare word `hooks` — and hooks are the contribution
that runs programs. Consenting on that word is consenting to argv nobody named.
The reader goes when the accessor lands.

**The marketplace pane used to prefill `/plugin add <absolute dir inside the
clone>`, and that took the path reading.** A stranger's bundle would have gone
straight on through the product's primary install flow, disclosure skipped. It
prefills the qualified name now.

Also fixes the confirmation this surface had drifted on: the title said
`Stop loading {id}?` over a row that had just offered to stop *declaring* it, and
it used a bare "leave it" literal with `index == 1` rather than `store::LEAVE_IT`
and `store::acts` — the one binary confirmation here that had come off the rule
the library exists to hold.
…ase falsified

A README section for marketplaces — adding one, what it holds, installing by
name, the ambiguity refusal, and the disclosure — plus the CHANGELOG entry and
the configuration example.

Four shipped claims went stale and are corrected rather than left:

- `docs/config.example.toml` said there is "no registry it fetches from". Half of
  that is still true and is worth keeping: io operates no index, hosts nothing,
  curates nothing, ranks nothing. The other half stopped being true, so the
  sentence now says both.
- The same file and the README described `/plugin` as two lists. There are three
  from 0.29.0, and the third — declared and switched off — is exactly the silent
  absence those paragraphs exist to argue against.
- `README.md`'s `/plugin add` described a path chosen from a picker or typed.
  It takes a name now, and the path-vs-name rule is stated where somebody
  choosing between them will read it.
- The hooks paragraph said io can never itemise them. It cannot from the typed
  API, and that is still what `/plugin` draws — but a marketplace install reads
  them from the manifest, because that is the one moment you are accepting a
  directory you have not read.

The configuration example also gains the `enabled` key and what writing it costs
an io-harness 0.69.0 binary, which is the one thing in this release an operator
has to know before downgrading rather than after.

The nightly rustdoc gate is clean — exit 0 with private items documented and
broken links, bare URLs and invalid HTML all denied. First release in five where
it found nothing.
…ught

Twelfth consecutive release in which this review found real defects behind a
fully green suite. Two reviewers, split by area, with the driver given its own
pass because nothing under `tests/` can link it.

**Two HIGH, and both are the same shape: a state exists that a surface cannot
show.**

`[[mcp]] enabled = false` is honoured by io-harness 0.70.0 before a server is
spawned, dialled or even checked against the policy — and io-cli read none of it.
`/mcp` drew "not reached this session", which is true of a switched-off server
and reads as one that has not been called yet; both headless verbs printed the
same columns as for a live server. This release fixed exactly that defect for
`[[plugin]]` and left the sibling surface blind in the same commit that took the
pin, while `src/servers.rs` carried a doc explaining why the key could not
exist — three clauses io-harness 0.70.0 had already falsified. Writing the key
stays the next release's verb; the test asserting that refusal now gives the real
reason for it.

Two bundles sharing a name inside one marketplace made that bundle permanently
uninstallable. The refusal counted hits rather than marketplaces, so it announced
"2 marketplaces" for one and printed the same qualified spelling twice — and
pasting either returned the identical refusal. Each is now offered by its own
directory, and `marketplace::offer` is the single speller, because the guided
browser was building the same string by hand and producing the same dead end.

**And the live suite caught what no offline gate could.** io-harness 0.70.0
closed io-cli's own #214: `Effect::Ask` on `Act::Exec` raises an approval instead
of refusing. So git is no longer refused under the posture the wizard recommends,
and `approval::refuses_git` — which answered `effect != Allow` and had zero
production callers — was both wrong and unused. Deleted rather than repaired: the
question is now answered by observation, in `App::note_git`, which fires on a
refusal the harness actually emitted. `/commit allow` survives for `read only`,
where the deny is real and there is nothing to answer.

The rest: manifest text from a stranger is filtered of control characters and
bounded before it is drawn, as `git`'s output already was; hook commands are no
longer elided on the one door where consent happens; six of this release's own
tests addressed bundles by index or count over `Config::discover`, which layers
the developer's real user configuration, and now address them by id; the plural
`/plugins <verb>` reaches its parse; the path escaping has an inverse; and F10's
`format!` ban is labelled the backstop it is, beside an assertion that actually
holds the property.
io-cli 0.29.0 — plugin marketplaces, and what a bundle is allowed to do shown before it is
@aakashpawar1999
aakashpawar1999 merged commit e644fc1 into main Aug 29, 2026
13 checks passed
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