From ffdabed8a4306c30f87e54d9df6051d23fddf57f Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 17:00:19 +0530 Subject: [PATCH 01/12] chore(deps): pin io-harness 0.70.0 0.70.0 adds a required `enabled` field to the public `McpServer` struct, so this is a compiling break rather than a mechanical bump. Three sites take it: `manage::mcp_add` now binds one `McpServer::stdio("", "")` and reads both `timeout_secs` and `enabled` off it, keeping the file's existing rule that a default is asked of the harness rather than written here as a literal; the two round-trip fixtures in `tests/servers.rs` name `true`, which is io-harness's own default and therefore what an entry written without the key reads back as. The flag has no keystroke in this release. `[[mcp]]` and `[[plugin]]` gaining `enabled` is what 0.30.0's roadmap entry was blocked on, and it stays there. --- Cargo.lock | 21 +++++++++++++++------ Cargo.toml | 2 +- src/manage.rs | 11 +++++++---- tests/servers.rs | 5 +++++ 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 589be0c..a91e7b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,7 +418,7 @@ dependencies = [ "encoding_rs", "fast-float2", "log", - "quick-xml", + "quick-xml 0.41.0", "serde", "zip", ] @@ -1006,7 +1006,7 @@ dependencies = [ "base64 0.22.1", "crc32fast", "image", - "quick-xml", + "quick-xml 0.41.0", "serde", "serde_json", "smallvec", @@ -1867,9 +1867,9 @@ dependencies = [ [[package]] name = "io-harness" -version = "0.69.0" +version = "0.70.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28c9b7027129f38c071489894f9e9760b7b027a5f5025d3af81944da258ce9df" +checksum = "ed49b9252e588eecd0fb3c19696dcdd35f5ca8ac0cd97ce2be2eef7a70fdb6dc" dependencies = [ "base64 0.23.1", "calamine", @@ -1879,7 +1879,7 @@ dependencies = [ "libc", "lopdf 0.44.0", "pdf-extract", - "quick-xml", + "quick-xml 0.42.0", "regex", "reqwest", "rmcp", @@ -2977,6 +2977,15 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.11" @@ -4518,7 +4527,7 @@ dependencies = [ "md-5 0.11.0", "num-traits", "phf 0.14.0", - "quick-xml", + "quick-xml 0.41.0", "rand 0.10.2", "rgb", "sha2 0.11.0", diff --git a/Cargo.toml b/Cargo.toml index 7a19929..e63d55b 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.69", features = ["media", "browser", "documents"] } +io-harness = { version = "0.70", 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/manage.rs b/src/manage.rs index c13fb1d..fcfb51a 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -822,13 +822,16 @@ fn mcp_add(args: &Args) -> Result { // 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. + // 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. + let defaults = McpServer::stdio("", ""); let mut server = McpServer { id, transport, - // Asked of the harness rather than written as a literal, the way - // `servers::add` asks it: the default is io-harness's, and a number - // copied into io-cli is one that goes stale in silence. - timeout_secs: McpServer::stdio("", "").timeout_secs, + timeout_secs: defaults.timeout_secs, + enabled: defaults.enabled, }; if let Some(seconds) = args.one("timeout-secs")? { server.timeout_secs = timeout(seconds)?; diff --git a/tests/servers.rs b/tests/servers.rs index 3fb2522..8e0a16c 100644 --- a/tests/servers.rs +++ b/tests/servers.rs @@ -628,6 +628,10 @@ fn f6_a_server_with_args_and_env_round_trips_as_the_harness_s_own_type() { env, }, timeout_secs: 30, + // io-harness 0.70.0's field. `true` is its own default, so an entry + // written without the key reads back as this — which is what makes the + // round-trip below an equality rather than a near miss. + enabled: true, }; // ONE edit, therefore one `configure::write`: a server whose `args` arrived @@ -660,6 +664,7 @@ fn f6_an_http_server_round_trips_with_its_headers() { headers, }, timeout_secs: 90, + enabled: true, }; let after = io_cli::edit::apply("", &[servers::add(&server)]).unwrap(); From 2bdfd99797ee21928875cb4cfaa068b689d5b9ca Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 17:12:55 +0530 Subject: [PATCH 02/12] fix(exec): name the outcome io-harness 0.70.0 split off, and keep the retry firing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.70.0 closes this crate's own io-harness#212. A run that reached its step cap having failed its criterion was `RunOutcome::StepCapReached` and is now `RunOutcome::VerificationFailed { steps }`; `StepCapReached` narrows to mean only that nothing judged the work. `RunOutcome` is `#[non_exhaustive]`, so none of this broke the build. Three things were quietly wrong instead: - `exec::code` fell to `_ => UNFINISHED`, so a failed-gate run exited 5 where it had exited 3, and where it should exit 6. It now answers `UNVERIFIED` directly. Mapping it to `CEILING` was the other candidate and is wrong: it would move exactly the runs exit 6 was invented for from 6 to 3 on a pin bump, changing a published exit table underneath somebody's CI. - `exec::describe` fell to its catch-all and printed "the run ended in a way this build does not have a name for", losing the step count. - **The gate retry stopped firing for the ending it was built for.** The driver admits `OK | CEILING`, and the comment above it says excluding ceilings would mean "the commonest failing-gate ending was never judged and never retried". 0.70.0 moved that ending out of `CEILING`. `UNVERIFIED` is now admitted beside them. The `UNVERIFIED` doc claimed io-harness had no variant for a criterion that answered no. It has one. `verified_code` stays as the second route rather than the only one — a gate can fail on a run that ended some other way, and only the recorded `GateOutcome` sees that. Gated by a driver-text test, because nothing under `tests/` links `src/main.rs`. --- src/exec.rs | 36 ++++++++++++++++++++++++++++-------- src/main.rs | 13 ++++++++++++- tests/gates.rs | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/exec.rs b/src/exec.rs index 1ec5a9e..06dc526 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -69,14 +69,20 @@ pub const UNFINISHED: u8 = 5; /// this is the first ending this subcommand has ever been able to tell apart /// from them. /// -/// It cannot be decided by [`code`], because io-harness has no `RunOutcome` -/// variant for a run whose criterion answered no. Such a run surfaces as -/// `StepCapReached` — the criterion is re-evaluated after every step and the loop -/// only ends early when it *passes*, so a gate that never passes spends the whole -/// budget. (Not as `Finished`: that variant requires `Verification::None`, so a -/// gated contract can never return it.) The verdict is therefore read from the -/// store after the run and applied by [`verified_code`]. Reported upstream as -/// io-harness#212. +/// **io-harness#212 is closed, and 0.70.0 is where this code stops being an +/// inference.** Until that release the harness had no `RunOutcome` variant for a +/// run whose criterion answered no: such a run surfaced as `StepCapReached` — +/// the criterion is re-evaluated after every step and the loop only ends early +/// when it *passes*, so a gate that never passes spends the whole budget — and +/// the verdict had to be read from the store afterwards by [`verified_code`]. +/// 0.70.0 adds `RunOutcome::VerificationFailed { steps }` for exactly that run, +/// and narrows `StepCapReached` to mean only that nothing judged the work. +/// +/// So [`code`] now decides it directly, and [`verified_code`] stays as the +/// second route rather than the only one. **The two must agree, and they do:** +/// both answer this constant. Keeping the store route is not redundancy — a +/// gate can fail on a run that ended for some other reason entirely, and only +/// the recorded `GateOutcome` sees that one. pub const UNVERIFIED: u8 = 6; /// The exit status for a run that reached the harness. @@ -118,6 +124,17 @@ pub fn code(outcome: &RunOutcome) -> u8 { | RunOutcome::CostBudgetExceeded { .. } | RunOutcome::BudgetCeilingReached { .. } => CEILING, + // io-harness 0.70.0, and it is this crate's own issue #212 coming back + // implemented. It is **not** a ceiling: the run did reach its step cap, + // but the fact worth reporting is that the work was judged and did not + // hold up, which is what `UNVERIFIED` has meant since 0.24.0. Mapping it + // to `CEILING` would move exactly the runs that code was invented for + // from 6 to 3 on a pin bump — a published exit table changing meaning + // underneath an operator's CI. `StepCapReached` above now means only + // that nothing judged the work, which is why the two can be told apart + // here at all. + RunOutcome::VerificationFailed { .. } => UNVERIFIED, + // `AwaitingApproval` stays unreachable from here while approvals are // denied rather than deferred; the other two are reachable, because a // question about intent and a proposed plan pass through no approver at @@ -184,6 +201,9 @@ pub fn describe(outcome: &RunOutcome) -> String { RunOutcome::Success { steps } => ("finished, and its verification passed", steps), RunOutcome::Finished { steps } => ("finished", steps), RunOutcome::StepCapReached { steps } => ("stopped at the step cap", steps), + RunOutcome::VerificationFailed { steps } => { + ("stopped at the step cap, and its verification failed", steps) + } RunOutcome::TimeBudgetExceeded { steps } => ("stopped at the time budget", steps), RunOutcome::CostBudgetExceeded { steps } => ("stopped at the token budget", steps), RunOutcome::BudgetCeilingReached { steps } => { diff --git a/src/main.rs b/src/main.rs index 534fb06..4aa6051 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6082,11 +6082,22 @@ async fn turn( // `StepCapReached` — that is what 0.24.0's own live arm recorded, `Failed` at // attempt six. Excluding ceilings would mean the commonest failing-gate ending // was never judged and never retried, which is the opposite of the intent. + // + // **`UNVERIFIED` counts for that same reason, and io-harness 0.70.0 is why it + // has to be named here.** That release split the ending above in two: a run + // that reached its cap having failed its criterion is now + // `RunOutcome::VerificationFailed` rather than `StepCapReached`, so it maps to + // `UNVERIFIED` and not to `CEILING`. The paragraph above describes exactly the + // run that moved. Admitting only `OK | CEILING` after the pin bump would have + // stopped the retry firing for the one ending it was built for — silently, + // behind a green build, because `RunOutcome` is `#[non_exhaustive]`. + // `code` can only answer `UNVERIFIED` for that variant; the store-derived + // verdict is `verified_code`'s and does not reach this call. let ran = match &outcome { Some(Ok(result)) if matches!( io_cli::exec::code(&result.outcome), - io_cli::exec::OK | io_cli::exec::CEILING + io_cli::exec::OK | io_cli::exec::CEILING | io_cli::exec::UNVERIFIED ) => { Some(result.run_id) diff --git a/tests/gates.rs b/tests/gates.rs index 3b5f08e..3584f72 100644 --- a/tests/gates.rs +++ b/tests/gates.rs @@ -811,3 +811,40 @@ fn f5_every_refusal_has_an_ascii_sentence_naming_the_key_to_change() { } } } + +/// **The retry admits the ending io-harness 0.70.0 invented for it.** +/// +/// A driver-text gate, and the only kind available: `src/main.rs` is the `[[bin]]` +/// and nothing under `tests/` can link it, so the set of outcomes handed to the +/// gate driver is a `matches!` this suite cannot call. +/// +/// 0.70.0 split the commonest failing-gate ending in two. A run that reached its +/// step cap having failed its criterion was `RunOutcome::StepCapReached` and is now +/// `RunOutcome::VerificationFailed`, which `exec::code` maps to `UNVERIFIED` rather +/// than to `CEILING`. `RunOutcome` is `#[non_exhaustive]`, so the pin bump moved +/// that run out of the admitted set **without breaking the build** — the retry +/// would simply have stopped firing for the one ending 0.24.0 built it for, behind +/// a green suite. +/// +/// Sabotage: drop `| io_cli::exec::UNVERIFIED` from the `matches!` in `src/main.rs`. +/// Only this test fails, and it fails naming the release that made the arm +/// necessary. +#[test] +fn the_gate_retry_admits_a_run_that_failed_its_verification() { + let driver = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs"); + let text = std::fs::read_to_string(driver).expect("the driver"); + // Line endings normalised: a Windows checkout has `\r\n`, and slicing on a + // bare `\n` has now shipped a defect in a test twice — 0.19.0 and 0.23.0. + let text = text.replace("\r\n", "\n"); + + assert!( + text.contains( + "io_cli::exec::OK | io_cli::exec::CEILING | io_cli::exec::UNVERIFIED" + ), + "the gate driver admits only the outcomes that mean the run reached its \ + own end, and since io-harness 0.70.0 a run that failed its criterion at \ + the step cap is one of them — `RunOutcome::VerificationFailed`, which \ + `exec::code` answers `UNVERIFIED` for. Omitting it stops the retry \ + firing for exactly the run it exists to retry", + ); +} From 4adc0ac4524e94b2ad2c927cc2200a69998ef64a Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 17:39:36 +0530 Subject: [PATCH 03/12] feat(plugin): show a bundle declared `enabled = false`, which the 0.70.0 pin hid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit io-harness 0.70.0 splits plugins into three buckets. `Plugins::iter`, `get`, `names`, `len` and `is_empty` all read `loaded` only — its own rustdoc says so — and `pluginview::view` read `iter()` and `dropped()`. So from the moment the pin landed, a bundle declared `enabled = false` appeared in neither `View::plugins` nor `View::refused`: `/plugin` showed nothing, `io plugin list` printed nothing, and `View::is_empty` answered true for a configuration declaring several, at which point the driver said "no capability bundles are declared yet" over a file declaring three. That is the failure this module exists to end, inverted. `view` now chains `plugins.disabled()` after `plugins.iter()`, with `Listed::enabled` carrying the state. The flag is an argument rather than a field read off the bundle, because the only public signal is which bucket it came back on — `Declaration::enabled` is `pub(crate)`. `DISABLED_MARK` is `-`, beside `+` and `!`; one ASCII character, so it survives the ASCII glyph set. `rows` keeps two ranges and three marks: a third range would put a third arm into every index calculation on this surface, which is the shape that shipped a wrong delete in 0.20.0. `bundle_skills` gains `.filter(|listed| listed.enabled)`, and that filter is the point rather than a detail. It reads off `pluginview::view`, so widening `view` without it would have put a switched-off bundle's skills into the palette while `TaskContract::discover_skills` never folded them in — the run then fails on a skill name the surface promised. `io plugin list` gains a third column, appended rather than inserted so a script reading the two it has always printed keeps working. Two hazards recorded rather than fixed: `Listed::id` is no longer unique across the list, because io-harness reserves no id for a disabled bundle, and nothing in io-cli keys on it; and a disabled row's detail is longer than a loaded one's, an overflow that already existed for loaded rows. --- src/main.rs | 57 ++++++++- src/pluginview.rs | 318 ++++++++++++++++++++++++++++++++++++++++++---- tests/plugins.rs | 211 ++++++++++++++++++++++++++++++ 3 files changed, 555 insertions(+), 31 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4aa6051..f572f9d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -728,10 +728,22 @@ fn import_rows( /// A bundle that declares no skills directory is absent rather than present and /// empty: it contributed nothing here, and a row saying so would be a row about /// the absence of a thing the operator never asked for. +/// +/// **And neither is a bundle declared `enabled = false`, which is the one thing +/// reading off `pluginview` costs.** From 0.29.0 `view().plugins` carries the +/// switched-off bundles too, so that `/plugin` can show an operator what they +/// declared — io-harness's own `Plugins::iter` never did, and `skill_dirs` is +/// built off it. The filter is what keeps the two readings the same: a +/// switched-off bundle contributes nothing to a turn, so offering the model its +/// skills would put a name in the palette that `discover_skills` never folded in, +/// and the run would fail on a skill the surface said was there. It would also +/// make `/plugin` report a missing skills directory as a per-turn error for a +/// bundle no turn touches. fn bundle_skills(config: &Config) -> Vec<(String, std::path::PathBuf)> { io_cli::pluginview::view(config) .plugins .into_iter() + .filter(|listed| listed.enabled) .filter_map(|listed| listed.skills.map(|dir| (listed.id, dir))) .collect() } @@ -1927,8 +1939,9 @@ async fn loop_over Result>( // and re-reading would answer a different question // than the one the operator asked by choosing a row. // - // The split is `pluginview::rows`'s own: loaded first, - // refused after, no headings, so the index is direct. + // The split is `pluginview::rows`'s own: `view.plugins` + // first — loaded then switched off — refused after, no + // headings, so the index is direct. if let Some(plugin) = view.plugins.get(index) { // `descended`, not `picker`: the assignment at the // end of this match installs it, while `kind` @@ -1955,7 +1968,16 @@ async fn loop_over Result>( // was already there, which cannot be wrong. let action_at = rows.len(); rows.push(Row::with_detail( - "stop loading this bundle".to_string(), + // A switched-off bundle is not loading, so + // offering to stop loading it names an action + // nobody can take. The entry is what both + // verbs actually remove. + if plugin.enabled { + "stop loading this bundle" + } else { + "stop declaring this bundle" + } + .to_string(), "removes its `[[plugin]]` entry".to_string(), )); descended = Some(( @@ -3893,6 +3915,14 @@ async fn loop_over Result>( // stays because it is still true and still orienting; what // changes is that the surface now offers the verb instead // of describing the file. + // + // **And in 0.29.0 it stopped being told to operators it + // was false for.** `View::is_empty` reads all three of + // io-harness's buckets from `pluginview::view`, so a + // configuration whose bundles are all declared + // `enabled = false` no longer gets "nothing is declared" + // printed over a file declaring several — it gets the + // list, under `pluginview::DISABLED_MARK`. app.record( Tone::Muted, "no capability bundles are declared yet".to_string(), @@ -7205,6 +7235,11 @@ enum Pick { /// sentence rather than a detail pane: there is nothing to descend into, /// because the bundle contributed nothing at all. /// + /// A bundle declared `enabled = false` is in `view.plugins` beside the loaded + /// ones, flagged by `pluginview::Listed::enabled`, and descends into the same + /// detail pane — every accessor is valid on one, and the pane opens on the row + /// that says none of it is in this session. + /// /// `add_at` is where the add row sits, recorded by the code that built the rows /// rather than recomputed here — `pluginview::rows` draws the loaded bundles /// and then the refused ones, so the only number that cannot be wrong is the @@ -7377,7 +7412,21 @@ fn manage_main( io_cli::manage::Request::Plugin(io_cli::manage::PluginVerb::List) => { let view = io_cli::pluginview::view(config); for listed in &view.plugins { - println!("{}\t{}", listed.id, listed.root.display()); + // **A third column, and it is not decoration.** From 0.29.0 this + // list carries the bundles declared `enabled = false` as well as + // the ones that loaded — the same three buckets `/plugin` draws, + // because a headless listing that showed fewer bundles than the + // panel would be a second, weaker truth about one configuration. + // A row without the state would then say a switched-off bundle is + // contributing, which is worse than omitting it. Appended rather + // than inserted, so a script reading the two columns this verb has + // always printed keeps reading them. + println!( + "{}\t{}\t{}", + listed.id, + listed.root.display(), + if listed.enabled { "loaded" } else { "disabled" }, + ); } for refused in &view.refused { // stderr, because a refused bundle is not part of the list a diff --git a/src/pluginview.rs b/src/pluginview.rs index a2f4df0..8c2fd7b 100644 --- a/src/pluginview.rs +++ b/src/pluginview.rs @@ -32,6 +32,35 @@ //! to fit a row, with the mark that says so; [`Refused::error`] holds it whole, //! and the driver prints it whole beneath the list. //! +//! # A switched-off bundle is declared, and this surface says so +//! +//! io-harness 0.70.0 splits what a configuration declared into **three** buckets +//! rather than two: `Plugins::iter` is what loaded, `Plugins::dropped` is what was +//! refused, and [`io_harness::Plugins::disabled`] is what was written +//! `enabled = false` — read, parsed, held to the whole trust rule, and +//! contributing nothing to any of the six kinds. That is a third *state*, never a +//! second kind of failure: everything on `dropped` is something an operator has to +//! fix, and a switched-off bundle is doing exactly what the file asked of it. +//! +//! **It is listed, under its own mark, and it counts against [`View::is_empty`].** +//! Before 0.29.0 [`view`] read `iter()` and `dropped()` and nothing else, so a +//! configuration declaring three bundles switched off drew an empty list and +//! `/plugin` said *no capability bundles are declared yet* — the section above, +//! inverted: a capability missing from every listing reads exactly like one nobody +//! ever wrote down, and the operator's own `enabled = false` is the one edit they +//! can undo in a keystroke if they can see it. What a switched-off bundle *would* +//! bring is drawn beside it for the same reason — the question an operator +//! switching one back on has is what comes back. +//! +//! **The state rides on [`Listed::enabled`] rather than a third list**, because +//! every index on this surface addresses a list somewhere else and a third one is +//! a third off-by-one — the shape of the silent wrong delete 0.20.0 shipped in +//! [`rows`]. The cost is one caller that has to remember the flag: +//! `src/main.rs`'s `bundle_skills` builds the `/skills` palette off [`view`], +//! because `Plugins::skill_dirs` is `pub(crate)`, and it filters on +//! [`Listed::enabled`] so a switched-off bundle's skills are not offered to a +//! model that cannot reach them. +//! //! # Hooks cannot be listed, and this file says so on screen //! //! There is no `Plugin::hooks()` and `Hook` is `pub(crate)`. A bundle's hooks are @@ -83,6 +112,15 @@ pub const LOADED_MARK: &str = "+"; /// The mark on a bundle that was declared and did not load. See [`LOADED_MARK`]. pub const REFUSED_MARK: &str = "!"; +/// The mark on a bundle declared `enabled = false`. See [`LOADED_MARK`]. +/// +/// `-` against `+`, which is the one pairing that needs no legend: it is the same +/// opposition [`crate::commands`] draws between present and absent, it is one +/// ASCII character in both glyph sets, and it cannot be confused with the `!` that +/// means a bundle is broken — the distinction the whole third state exists to +/// draw, since a switched-off bundle is nothing for the operator to fix. +pub const DISABLED_MARK: &str = "-"; + /// The narrowest a bundle's root may be drawn at, before its separator. /// /// Twenty cells is `...bundles/rust-review` — the last segments and the mark @@ -92,7 +130,8 @@ pub const REFUSED_MARK: &str = "!"; /// lose a fact, and may not draw one that cannot be read. const ROOT_FLOOR: usize = 20; -/// One bundle that loaded, as `/plugin` lists it. +/// One bundle io-harness read, as `/plugin` lists it — loaded, or declared and +/// switched off. /// /// Everything is owned and copied out of the borrowed [`io_harness::Plugin`], /// because `Config::plugins()` returns a fresh value that re-read the disk: the @@ -102,7 +141,23 @@ const ROOT_FLOOR: usize = 20; pub struct Listed { /// The manifest's `name`, which is also what every contribution below is /// namespaced by. + /// + /// **Unique among the loaded bundles and not across the whole list.** + /// io-harness reserves an id only for a bundle it switched on, so two + /// bundles declared `enabled = false` may share one — which is exactly the + /// swap the flag exists for, `tools-v1` off beside `tools-v2` on. Nothing + /// here may key on it; [`Listed::root`] is what identifies a bundle. pub id: String, + /// Whether the `[[plugin]]` entry that declared it said so, or said + /// `enabled = false` (io-harness 0.70.0, and absent means on). + /// + /// **False means this bundle contributed nothing at all** — not its skills, + /// not its agents, not its servers, not its hooks — while every field below + /// still reads, because io-harness parses and validates a switched-off bundle + /// in full. So they describe what switching it back on would bring, and a + /// caller that installs any of them must check this first. See the module + /// docs and `bundle_skills` in `src/main.rs`. + pub enabled: bool, /// The manifest's one line for a human, if it carried one. pub description: Option, /// The bundle's own version. **Documentation and nothing else** — io-harness @@ -154,8 +209,14 @@ pub struct Refused { /// a refused bundle sits beside four that loaded and both facts are true at once. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct View { - /// The bundles that loaded, in the order they were declared — which is the - /// order their policy layers stack and their contributions are applied in. + /// The bundles io-harness read: the loaded ones first, in the order they were + /// declared — which is the order their policy layers stack and their + /// contributions are applied in — and then the ones declared + /// `enabled = false`, each flagged by [`Listed::enabled`]. + /// + /// **One list and a flag, not two lists.** See the module docs: a third list + /// is a third index, and every index on this surface addresses a list + /// somewhere else. pub plugins: Vec, /// The bundles that did not, with the reason each one did not. pub refused: Vec, @@ -169,13 +230,59 @@ impl View { /// surface that read it alone would tell an operator with a broken manifest /// that they have no plugins — which is the false sentence this module exists /// to stop being told. + /// + /// **And a configuration whose only bundle is switched off has declared one + /// too**, which is the same sentence with a different cause: `disabled()` is + /// a third bucket `Plugins::is_empty` is equally silent about. Those bundles + /// are in `plugins` flagged rather than in a list of their own, so this + /// answers `false` for them without a third clause — and the sabotage that + /// finds a regression here is dropping `disabled()` from [`view`], not + /// editing this line. #[must_use] pub fn is_empty(&self) -> bool { self.plugins.is_empty() && self.refused.is_empty() } } -/// Every bundle this configuration declared, loaded and refused. +/// One [`io_harness::Plugin`], copied out with the state io-harness kept in the +/// bucket it put it in. +/// +/// `enabled` is an argument rather than something read off the plugin because +/// there is nothing on `Plugin` to read: the flag lives on the `[[plugin]]` entry +/// 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 { + Listed { + id: plugin.id().to_string(), + enabled, + description: plugin.description().map(str::to_string), + version: plugin.version().map(str::to_string), + root: plugin.root().to_path_buf(), + contributions: plugin.contributions(), + skills: plugin.skills_dir(), + templates: plugin.templates_dir(), + agents: plugin.agents().iter().map(|def| def.name.clone()).collect(), + servers: plugin + .mcp_servers() + .iter() + .map(|server| server.id.clone()) + .collect(), + layers: plugin + .policy_layers() + .iter() + .map(|layer| layer.name.clone()) + .collect(), + } +} + +/// 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 +/// changed in 0.29.0.** `Plugins::iter` is the loaded set alone — its own rustdoc +/// says so of `len` and `is_empty` — so a `view` reading it and `dropped()` was +/// blind to every bundle an operator had declared `enabled = false`, and drew them +/// nowhere at all. See the module docs. /// /// Infallible, because [`io_harness::Config::plugins`] is: it re-reads every /// declared directory from disk on the call and reports what it could not read @@ -186,28 +293,17 @@ impl View { pub fn view(config: &io_harness::Config) -> View { let plugins = config.plugins(); View { + // Loaded first and switched off after, which is the order [`rows`] draws + // and the order the positional contract there is written against. plugins: plugins .iter() - .map(|plugin| Listed { - id: plugin.id().to_string(), - description: plugin.description().map(str::to_string), - version: plugin.version().map(str::to_string), - root: plugin.root().to_path_buf(), - contributions: plugin.contributions(), - skills: plugin.skills_dir(), - templates: plugin.templates_dir(), - agents: plugin.agents().iter().map(|def| def.name.clone()).collect(), - servers: plugin - .mcp_servers() - .iter() - .map(|server| server.id.clone()) - .collect(), - layers: plugin - .policy_layers() + .map(|plugin| copy_out(plugin, true)) + .chain( + plugins + .disabled() .iter() - .map(|layer| layer.name.clone()) - .collect(), - }) + .map(|plugin| copy_out(plugin, false)), + ) .collect(), refused: plugins .dropped() @@ -223,8 +319,8 @@ pub fn view(config: &io_harness::Config) -> View { /// The picker rows for the whole list, fitted for a terminal this wide. /// -/// **One row per bundle, loaded first and refused after, and no headings.** The -/// picker hands a chosen index straight back into the caller's own list, so a +/// **One row per bundle, `view.plugins` first and refused after, and no +/// headings.** The picker hands a chosen index straight back into the list, so a /// heading row is an index that maps to no bundle — [`crate::commands`] carries a /// parallel vector of `Held::Nothing` to survive that, and this surface has two /// lists to index rather than one. So the contract is positional and written down: @@ -233,6 +329,13 @@ pub fn view(config: &io_harness::Config) -> View { /// says which — and unlike a heading it survives a typed query, which is exactly /// when a refused bundle is hardest to tell from a loaded one. /// +/// **A bundle declared `enabled = false` is inside `view.plugins` and so is inside +/// that first range**, drawn under [`DISABLED_MARK`] with the state leading its +/// detail. Three marks and still two ranges: the switched-off bundles are a state +/// the operator chose, not a second failure list, and giving them a range of their +/// own would have added a third arm to every index calculation on this surface for +/// nothing. +/// /// # The narrow form, and what gives way in it /// /// The id is the label. The contributions are unconditional, because they are the @@ -262,7 +365,21 @@ pub fn rows(view: &View, width: u16, glyphs: &Glyphs) -> Vec { // is one fact — the list of kinds — and punctuating it with the same run // that divides the row would read as five fields. let mut detail = plugin.contributions.join(", "); - if detail.is_empty() { + if !plugin.enabled { + // **The state leads the row, and the mark is not left to carry it + // alone.** A single character says which bucket a row came from; it + // does not say that `skills, agents` is a list of things this session + // does *not* have. Reading the kinds first on a switched-off bundle is + // the one wrong sentence this row can tell, so the words go in front + // of them — two words and not a clause, because the contributions are + // unconditional here and every cell spent in front of them is a cell + // the eighty-column row (N4) has to find somewhere else. + detail = if detail.is_empty() { + "switched off".to_string() + } else { + format!("switched off{separator}{detail}") + }; + } else if detail.is_empty() { // A manifest with a `name` and nothing else. Loaded, contributing // nothing, and saying so is the whole point of the row. detail = "contributes nothing".to_string(); @@ -299,7 +416,15 @@ pub fn rows(view: &View, width: u16, glyphs: &Glyphs) -> Vec { )); } - out.push(Row::marked(LOADED_MARK, plugin.id.clone(), detail)); + out.push(Row::marked( + if plugin.enabled { + LOADED_MARK + } else { + DISABLED_MARK + }, + plugin.id.clone(), + detail, + )); } for refused in &view.refused { @@ -330,6 +455,12 @@ pub fn rows(view: &View, width: u16, glyphs: &Glyphs) -> Vec { /// /// Every name is drawn **namespaced, exactly as io-harness rewrote it** — see the /// module docs. +/// +/// A switched-off bundle gets the same groups with one row above them saying that +/// none of it is in this session. Every accessor is valid on one — io-harness +/// parses and validates a disabled bundle in full — so the groups are true about +/// the *directory* and false about the *session*, and the row above them is what +/// makes the pane say which. See [`Listed::enabled`]. 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. @@ -383,6 +514,28 @@ pub fn detail(plugin: &Listed, width: u16, glyphs: &Glyphs) -> Vec { // in the list; this is the row that says the list entry is all there is. out.push(Row::new("this bundle contributes nothing")); } + if !plugin.enabled { + // **First, because every row under it is false without it.** io-harness + // parses and validates a switched-off bundle in full, so the groups above + // are the real agents, servers and layers of a real directory — and this + // session has none of them. A pane that opened on `agents` over a bundle + // contributing no agent is the same silent wrong reading the module docs + // exist to end, told the other way round. `insert` rather than a branch + // around the build: the groups are what switching it on brings back, and + // an operator deciding whether to do that needs to see them. + out.insert( + 0, + Row::with_detail( + "switched off", + fit( + "this bundle's `[[plugin]]` entry says `enabled = false`, so none of what \ + follows is in this session; it is what switching it back on would bring", + room.saturating_sub("switched off".chars().count() + 2), + glyphs, + ), + ), + ); + } out } @@ -606,6 +759,7 @@ mod tests { fn listed() -> Listed { Listed { id: "rust-review".to_string(), + enabled: true, description: Some("Everything our Rust reviews need.".to_string()), version: Some("1.2.0".to_string()), root: PathBuf::from("/Users/someone/code/io-cli/bundles/rust-review"), @@ -692,6 +846,115 @@ mod tests { assert!(detail.starts_with("contributes nothing"), "{detail}"); } + /// The three states are three marks, and a switched-off bundle leads with the + /// state rather than with a list of contributions this session does not have. + /// + /// Sabotage: draw the disabled rows under `LOADED_MARK`. Under it the list + /// says four bundles are loaded when one of them contributes nothing, and the + /// operator's `enabled = false` is invisible on the surface that exists to + /// show what is declared. + #[test] + fn a_switched_off_bundle_is_marked_apart_from_both_loaded_and_refused() { + let mut off = listed(); + off.id = "tools-v1".to_string(); + off.enabled = false; + let view = View { + plugins: vec![listed(), off], + refused: vec![Refused { + id: "empty".to_string(), + path: PathBuf::from("/repo/bundles/empty"), + error: "/repo/bundles/empty: no plugin.toml".to_string(), + }], + }; + for glyphs in [&UNICODE, &ASCII] { + let rows = rows(&view, 200, glyphs); + assert_eq!(rows.len(), 3, "{}", glyphs.name); + // Three states, three marks, and no two of them the same — which is + // the whole of what a mark on this surface has to do. + assert_eq!(rows[0].mark, Some(LOADED_MARK), "{}", glyphs.name); + assert_eq!(rows[1].mark, Some(DISABLED_MARK), "{}", glyphs.name); + assert_eq!(rows[2].mark, Some(REFUSED_MARK), "{}", glyphs.name); + assert_eq!(rows[1].label, "tools-v1", "{}", glyphs.name); + let detail = rows[1] + .detail + .clone() + .expect("a disabled bundle has detail"); + assert_eq!( + detail.split(glyphs.separator).next(), + Some("switched off"), + "{}: the state is not the first field of the row: {detail}", + glyphs.name, + ); + assert!( + detail.contains("skills, agents, hooks, policy"), + "{}: what switching it back on would bring is not on the row: {detail}", + glyphs.name, + ); + } + } + + /// A switched-off bundle with nothing to contribute says only that it is off, + /// rather than saying it twice in two different phrasings. + #[test] + fn a_switched_off_bundle_with_no_contributions_says_only_that_it_is_off() { + let mut off = listed(); + off.enabled = false; + off.contributions = Vec::new(); + let rows = rows(&view_of(off), 120, &UNICODE); + let detail = rows[0] + .detail + .clone() + .expect("a disabled bundle has detail"); + assert!(detail.starts_with("switched off"), "{detail}"); + assert!( + !detail.contains("contributes nothing"), + "the loaded-and-empty phrasing is drawn over a bundle it is not \ + about: {detail}", + ); + } + + /// The detail pane of a switched-off bundle opens on the fact that none of it + /// is in the session, above the groups that would otherwise read as a list of + /// what this session has. + /// + /// Sabotage: drop the `insert(0, …)`. Under it the pane opens on `skills` and + /// a namespaced agent name for a bundle contributing neither, which is the + /// module's own silent-wrong-reading defect told the other way round. + #[test] + fn the_detail_view_of_a_switched_off_bundle_says_none_of_it_is_in_the_session() { + let mut off = listed(); + off.enabled = false; + for glyphs in [&UNICODE, &ASCII] { + let rows = detail(&off, 120, glyphs); + assert_eq!(rows[0].label, "switched off", "{}", glyphs.name); + let said = rows[0] + .detail + .clone() + .expect("the switched-off row explains itself"); + assert!( + said.contains("enabled = false"), + "{}: the row names the key an operator would edit: {said}", + glyphs.name, + ); + // And the groups are still drawn, because they are what switching it + // back on brings back. + assert!( + rows.iter().any(|row| row.label == "agents"), + "{}: the contributions are still shown", + glyphs.name, + ); + // 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) + .iter() + .all(|row| row.label != "switched off"), + "{}", + glyphs.name, + ); + } + } + #[test] fn a_refused_bundle_is_marked_apart_and_carries_the_harness_sentence() { let sentence = "/repo/bundles/empty: no plugin.toml; a plugin is a directory with a \ @@ -820,6 +1083,7 @@ mod tests { fn a_bundle_with_nothing_listable_still_draws_a_row() { let plugin = Listed { id: "bare".to_string(), + enabled: true, description: None, version: None, root: PathBuf::from("/repo/bare"), diff --git a/tests/plugins.rs b/tests/plugins.rs index ace9374..51d5be5 100644 --- a/tests/plugins.rs +++ b/tests/plugins.rs @@ -1095,3 +1095,214 @@ fn f11_an_added_bundle_loads_through_the_harness() { vec!["rust-review"], ); } + +// --------------------------------------------------------------------------- +// F7 — a bundle declared `enabled = false` is declared, off, and visible +// --------------------------------------------------------------------------- +// +// io-harness 0.70.0 splits what a configuration declared into three buckets: +// `Plugins::iter` is what loaded, `Plugins::dropped` is what was refused, and +// `Plugins::disabled` is what was written `enabled = false` — read, parsed, held +// to the whole trust rule, contributing nothing. `Plugins::len` and +// `Plugins::is_empty` say so in their own rustdoc: they answer about the loaded +// bucket alone. +// +// Everything below is about the half of F7 that io-cli owns. The declining verb +// that writes the key is a separate criterion; these tests write the key by hand, +// which is also how an operator with an editor arrives at this state, and assert +// that `/plugin` can see it. A bundle absent from every listing reads exactly like +// one nobody ever declared — and until 0.29.0 `pluginview::view` read `iter()` and +// `dropped()` and nothing else, so it was absent from every listing io-cli has. + +/// A configuration declaring `path` as a bundle switched off, and nothing else. +fn declaring_off(root: &Path, path: &str) { + std::fs::write( + root.join(LOCAL_FILE), + format!("[[plugin]]\npath = \"{path}\"\nenabled = false\n"), + ) + .expect("the configuration"); +} + +/// **F7, the visibility half.** A configuration declaring exactly one bundle, off, +/// is not a configuration with no bundles — `View::is_empty` is `false` and the +/// bundle is in the list, flagged. +/// +/// The three assertions are three different claims and each one can fail alone: +/// io-harness put the bundle on `disabled()` rather than loading or dropping it, +/// io-cli carried that bucket into the view, and `is_empty` therefore answers for +/// a declaration rather than for a load. +/// +/// Sabotage: drop the `.chain(plugins.disabled()…)` from `pluginview::view`. Under +/// it `view.plugins` is empty, `View::is_empty` returns `true`, and `/plugin` +/// prints "no capability bundles are declared yet" over an `io.toml` declaring +/// one — which is the false sentence `src/pluginview.rs`'s module docs exist to +/// end, told about a bundle the operator switched off themselves and can switch +/// back on in one keystroke if they can see it. +/// +/// Second sabotage: mark the disabled bundles `enabled: true` in `view`. Under it +/// this test still passes on `is_empty` and fails on the flag — which is the +/// assertion that makes the flag load-bearing rather than decorative, since +/// `bundle_skills` in `src/main.rs` filters the skills palette on it. +#[test] +fn f7_a_configuration_declaring_only_a_switched_off_bundle_is_not_empty() { + let (_dir, root) = root(); + bundle(&root, "bundles/rust-review", MINIMAL); + declaring_off(&root, "bundles/rust-review"); + + let config = Config::discover(&root).expect("a switched-off bundle is not a broken file"); + let plugins = config.plugins(); + + // io-harness's three buckets, asserted where they are — so a change of + // bucket in a future pin fails here rather than somewhere downstream. + assert!( + plugins.is_empty(), + "a switched-off bundle loaded: {:?}", + plugins.names(), + ); + assert!( + plugins.dropped().is_empty(), + "`enabled = false` was treated as a failure rather than a choice: {:?}", + plugins + .dropped() + .iter() + .map(|d| d.error.clone()) + .collect::>(), + ); + assert_eq!(plugins.disabled().len(), 1); + + // And io-cli's surface, which is what the criterion is about. + let view = pluginview::view(&config); + assert_eq!( + view.plugins.len(), + 1, + "the switched-off bundle is in neither list, so `/plugin` draws nothing \ + for an `io.toml` that declares it", + ); + assert!( + view.refused.is_empty(), + "a switched-off bundle was carried as a refusal, which tells the operator \ + to fix something that is not broken: {:?}", + view.refused, + ); + assert_eq!(view.plugins[0].id, "rust-review"); + assert!( + !view.plugins[0].enabled, + "the bundle is listed as loaded, so nothing on this surface says the \ + operator switched it off", + ); + assert!( + !view.is_empty(), + "`View::is_empty` is true for a configuration declaring a bundle", + ); +} + +/// **F7 rendering.** The switched-off bundle draws under its own mark, with the +/// state leading the row rather than a list of contributions this session has not +/// got. +/// +/// Sabotage, either half: drop the disabled bucket from `pluginview::view` and +/// there is no row at all — the length assertion fails before the mark is read. +/// Draw the row under `LOADED_MARK` and the list says the bundle is contributing +/// while `Config::plugins()` says it contributes nothing, which is two surfaces +/// disagreeing about one bundle in the one direction an operator cannot detect: +/// the panel is the only place they look. +#[test] +fn f7_a_switched_off_bundle_draws_under_its_own_mark() { + let (_dir, root) = root(); + bundle(&root, "bundles/rust-review", MINIMAL); + declaring_off(&root, "bundles/rust-review"); + let config = Config::discover(&root).expect("the configuration loads"); + let view = pluginview::view(&config); + + for glyphs in [&io_cli::glyphs::UNICODE, &io_cli::glyphs::ASCII] { + // Wide enough that nothing is shortened, so the words below are compared + // rather than prefix-matched. + let rows = pluginview::rows(&view, 400, glyphs); + assert_eq!( + rows.len(), + 1, + "{}: one declared bundle, one row", + glyphs.name, + ); + assert_eq!(rows[0].label, "rust-review", "{}", glyphs.name); + assert_eq!( + rows[0].mark, + Some(pluginview::DISABLED_MARK), + "{}: the switched-off bundle is not marked apart from a loaded one", + glyphs.name, + ); + assert_ne!( + pluginview::DISABLED_MARK, + pluginview::LOADED_MARK, + "the two states share a mark, so the row cannot say which it is", + ); + assert_ne!( + pluginview::DISABLED_MARK, + pluginview::REFUSED_MARK, + "a switched-off bundle wears the mark that means something is broken", + ); + + let detail = rows[0] + .detail + .clone() + .expect("a switched-off bundle has a detail"); + assert_eq!( + detail.split(glyphs.separator).next(), + Some("switched off"), + "{}: the row leads with what the bundle contributed, which it did \ + not: {detail}", + glyphs.name, + ); + assert!( + detail.contains("skills, templates, agents, policy"), + "{}: what switching it back on would bring is not on the row: {detail}", + glyphs.name, + ); + } +} + +/// **F7, and the bug the flag exists to stop.** A switched-off bundle is on the +/// list and on no turn: nothing it declares reaches the contract, and its skills +/// directory is still readable — so the flag, not an absent field, is the only +/// thing keeping it out of the `/skills` palette. +/// +/// Sabotage: drop the `.filter(|listed| listed.enabled)` from `bundle_skills` in +/// `src/main.rs`. Under it `/skills` offers the model a skill from a bundle +/// `TaskContract::discover_skills` never folded in, and the run fails on a name +/// the surface said was there. That filter is not visible from an integration +/// test — `bundle_skills` is private to the binary — so what is asserted here is +/// the fact that makes it necessary: `Listed::skills` is `Some` for a bundle +/// contributing nothing. +#[test] +fn f7_a_switched_off_bundle_reaches_no_turn_while_its_directories_still_read() { + let (_dir, root) = root(); + bundle(&root, "bundles/rust-review", MINIMAL); + declaring_off(&root, "bundles/rust-review"); + let config = Config::discover(&root).expect("the configuration loads"); + + let contract = io_cli::contract::configured("review this", root.clone(), &config); + assert!( + contract.agents.get("rust-review__reviewer").is_none(), + "a switched-off bundle put an agent on the contract: {:?}", + contract.agents.names(), + ); + assert_eq!( + contract.plugins.len(), + 0, + "`contract.plugins` is what `discover_skills` folds a bundle's skills in \ + from, and a switched-off bundle is in it", + ); + + let view = pluginview::view(&config); + assert!( + view.plugins[0].skills.is_some(), + "the switched-off bundle's skills directory did not read, so this test \ + would pass for the wrong reason and `bundle_skills` would look safe", + ); + assert_eq!( + view.plugins[0].agents, + vec!["rust-review__reviewer"], + "io-harness namespaces a switched-off bundle's names too, so the detail \ + pane can say what switching it on would bring", + ); +} From e4bacf84e5628264b1236256e18a2bc09dfd8ad7 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 17:40:05 +0530 Subject: [PATCH 04/12] feat(fetch): clone a marketplace, and amend the spawn gate rather than relax it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit io-harness owns git and publishes no way to run a subcommand of it: `Git`, `Git::run` and `GitCmd` are `pub(crate)`, and its own tests assert no argv it builds can carry `clone`, `fetch` or `push`. That absence is deliberate there, so a marketplace cannot be brought down through the dependency. The only alternative to spawning `git` here is an HTTP client with a TLS stack under it and a second network path beside io-harness's, which is a bigger hole in N1 than one more permitted path. `src/fetch.rs` carries the crate's second and last spawn. The program is the constant `PROGRAM = "git"`; `argv` returns a `Vec` of separate elements with nothing interpolated, so a repository name cannot become part of a flag. An existing destination is answered before anything is spawned, the clone assembles at a staging path and is moved into place, and the staging path is cleared before and after — so the only thing that can survive the call is a complete tree at the destination. stdin is null and `GIT_TERMINAL_PROMPT` is `0`, so a private URL fails with a sentence instead of hanging the viewport on a credential prompt. Both paths are the caller's, which is what lets the tests drive every ending against a local repository. `tests/dependencies.rs` is amended, not relaxed. `spawning_module` becomes `spawning_modules`, a set of two exact paths, sorted, compared as a set. The rustdoc names the sabotage it must survive: a gate written as `path.contains("fetch")` would admit `src/fetch/anything.rs` and go on passing while a third module spawned, because a substring match is a permission list that widens itself. The aliased spellings stay banned everywhere, `src/fetch.rs` names none of the run-event types, and `src/shell.rs` is still reachable from `src/main.rs` alone. `~/.io-cli/marketplaces` is created beside `skills/`, best-effort and 0700, the way that one is. Nothing calls this module yet — `/plugin marketplace add` wires it next. --- src/fetch.rs | 463 ++++++++++++++++++++++++++++++++++++++++++ src/home.rs | 60 ++++++ src/lib.rs | 9 + tests/dependencies.rs | 198 +++++++++++++++--- tests/fetch.rs | 391 +++++++++++++++++++++++++++++++++++ 5 files changed, 1090 insertions(+), 31 deletions(-) create mode 100644 src/fetch.rs create mode 100644 tests/fetch.rs diff --git a/src/fetch.rs b/src/fetch.rs new file mode 100644 index 0000000..a3daf35 --- /dev/null +++ b/src/fetch.rs @@ -0,0 +1,463 @@ +//! Bringing a marketplace onto the disk — the one `git` this crate runs. +//! +//! **This is the second module in `src/` permitted a process spawn, and +//! `tests/dependencies.rs` names it by path to permit it.** The gate is amended +//! rather than relaxed: the permitted set is a set of *exact paths*, a third file +//! naming the literal still fails and fails naming both permitted paths, and the +//! aliased spellings stay banned everywhere — this module included, which is why +//! the import below is written out one name per line. The shape is 0.17.0's and +//! 0.26.0's amendment of the provider gate: an exemption by path, held to +//! properties that are asserted rather than promised. +//! +//! **Why a spawn at all.** io-harness owns git and publishes no way to run it: +//! `Git`, `Git::run` and `GitCmd` are all `pub(crate)` in its `tools/git.rs`, and +//! that file's own tests assert that no argv the harness can build ever carries +//! `clone`, `fetch` or `push`. Its engine exists for a workspace the agent is +//! already inside, not for bringing a new one down, so there is no governed call +//! to reach for. The alternative is an HTTP client — an eleventh direct +//! dependency, a TLS stack under it, and a second network path beside the one +//! io-harness owns, which is the thing this product exists not to grow. Cloning +//! also makes updating a marketplace a pull and pinning one a ref rather than two +//! features this crate would otherwise have to invent. +//! +//! `product.yaml:253-258` records the price honestly: that `git` is present is an +//! **assumption about developer machines, not a fact this repository has +//! checked**. So a machine without it gets [`Fetched::NoGit`] and the sentence +//! [`Fetched::sentence`] writes, never a panic and never a stack trace — and the +//! route that installs a plugin from a directory the operator already has does not +//! go through here at all. +//! +//! **The argv is a value, not a string.** [`argv`] is a pure function returning +//! five owned elements — `clone`, `--depth`, `1`, the URL, the destination — and +//! nothing in this file ever splices a name, a URL or a path into a larger string +//! for a program to take apart again. No shell is invoked, so there is no quoting +//! question to get wrong and no metacharacter in a repository name to think about. +//! `tests/fetch.rs` asserts the URL and the path arrive as their own elements, +//! byte for byte, rather than trusting that they do, and `tests/dependencies.rs` +//! asserts this file builds no argument with `format!` — which is the sabotage F10 +//! names by name. +//! +//! **Nothing io-harness drives can reach this.** The module names none of the +//! types the event stream, the conversation or the trace are made of, exactly as +//! `src/shell.rs` names none of them, and `tests/dependencies.rs` asserts that of +//! both permitted modules in one loop. A marketplace is fetched because the +//! operator typed a name. +//! +//! **A failure at any point leaves no half-cloned directory presented as a +//! marketplace, and the mechanism is a rename rather than a cleanup.** git clones +//! into `~/.io-cli/.fetching/` and the finished tree is moved into +//! `~/.io-cli/marketplaces//` in one step. Both paths are inside the +//! operator's own home, so the move is a same-filesystem rename and there is no +//! cross-device copy that could itself stop half way. +//! +//! Removing the destination on failure would have been fewer lines, and it covers +//! every failure this process survives to see. It covers none of the ones it does +//! not: a `kill -9`, a panic in another thread, or the machine losing power in the +//! middle of a clone is exactly how a directory holding half a `plugin.toml` ends +//! up under a path the listing walks and the installer trusts. The staging +//! directory is dot-named and sits *outside* the marketplaces tree for the same +//! reason — whatever is left in it after a kill can never be counted as a +//! marketplace, because nothing walks it. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +// One name per line, and `std::process::Command` written out in full. A braced or +// aliased import would put a spawn in a file where the literal never appears, +// which is the evasion `tests/dependencies.rs` forbids in every file — this one +// included, because a permission that can be spelled around is not a permission. +use std::process::Command; +use std::process::Stdio; + +/// The program, and the whole of it. +/// +/// A constant rather than a literal at the call site so that +/// `tests/dependencies.rs` can assert both halves of the property F10 states: that +/// this is the string `git`, and that the single `Command::new` in this file takes +/// *this* and not a name computed from anything. A program that came from a +/// variable is how a spawn stops being a spawn of git. +pub const PROGRAM: &str = "git"; + +/// The only forge a bare `/` resolves against. +/// +/// **A stated ceiling, not an oversight.** The release adds a marketplace *by +/// name*, and one host is what makes a name short enough to type and to say out +/// loud. An operator on another forge is not served by this release, and the +/// upgrade is one more accepted grammar in [`resolve`] — not a change here, and +/// not a URL passed through unread: an argument that reaches `git clone` having +/// been typed by somebody is how `ext::sh -c …` becomes a remote shell, and +/// [`resolve`] refuses everything that is not two ordinary path segments precisely +/// so that this string is the only host that can ever be reached. +const HOST: &str = "https://github.com/"; + +/// The variable that stops git asking for a credential on a terminal io-cli owns. +/// +/// **A `/dev/null` stdin is not enough, and that is the whole reason this is +/// here.** git does not prompt on stdin: it opens `/dev/tty` directly, which is +/// still this process's terminal — in raw mode, with an inline viewport whose +/// absolute screen row was computed once and is maintained afterwards by +/// arithmetic alone. A credential prompt written there scrolls the screen behind +/// ratatui's back, and on this renderer the transcript *is* the scrollback and +/// cannot be redrawn, which is the damage `src/shell.rs` documents at length as +/// its reason never to hand the terminal over. Setting this makes git fail with a +/// sentence instead, and that sentence arrives here as the clone's own stderr. +/// +/// It is an environment variable and not an argument, so the argv stays exactly +/// the five elements [`argv`] returns. +const NO_PROMPT: &str = "GIT_TERMINAL_PROMPT"; + +/// What an operator is told when there is no git to run. +/// +/// Named, and about their machine rather than about this program. The second +/// sentence is the part that matters: this is the *only* thing io-cli does that +/// needs git, so the answer is not "io-cli does not work here". +const NO_GIT: &str = "no `git` on PATH — a marketplace is a repository io-cli clones, so \ + adding one needs git installed. Installing a plugin from a directory \ + you already have does not."; + +/// A marketplace named the way an operator says it: an owner and a repository. +/// +/// Two owned strings rather than one borrowed `&str`, because both halves become +/// path components and an argv element that outlives the text they were parsed +/// from. Kept separate rather than stored joined so that neither the URL nor the +/// destination has to take a string apart again to find them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Named { + /// The account or organisation that holds the repository. + pub owner: String, + /// The repository itself, with no `.git` on the end — [`resolve`] takes it + /// off, so the name that reaches a path and the name an operator typed are + /// the same name. + pub repo: String, +} + +/// What a fetch did. +/// +/// Four endings, and each is a different sentence to a different person: it +/// worked; it was already here; this machine has no git; git ran and said no. The +/// last two are deliberately not one variant — "git could not be started" and +/// "git started and refused" are the difference between installing a tool and +/// fixing a name, and folding them together is how an operator is told to install +/// something they already have. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Fetched { + /// The clone is in place, at this path. + Cloned(PathBuf), + /// Something is already at the destination, and it was left exactly as it + /// was. **Never cloned over**: the directory may hold a marketplace an + /// operator has installed plugins from, and a `[[plugin]] path` in their + /// configuration points into it. + Already(PathBuf), + /// There is no `git` on `PATH`. + NoGit, + /// git ran and did not succeed — or could not be started for a reason that is + /// not its absence, which is the same thing to the operator and a different + /// thing to anyone reading a bug report. + Failed { + /// The exit code, or `None` where the platform reported none — which on + /// unix means a signal ended it, and here also means the failure was not + /// an exit at all. + status: Option, + /// What git printed, with control characters dropped. + /// + /// **Cleaned at construction rather than at the point it is drawn.** This + /// is output from a program io-cli did not write, and on this renderer the + /// scrollback is the transcript: a `\x1b[3J` inside it would erase the + /// session's whole history. The same rule, and the same reason, as the + /// captured output of a `!` line. + stderr: String, + }, +} + +impl Fetched { + /// Which ending an [`std::io::Error`] from the spawn itself is. + /// + /// **The distinction is the whole reason this is a function.** `NotFound` from + /// a spawn means the program is not on `PATH`, which is the one failure this + /// release has to name in the operator's own words; every other kind — + /// `PermissionDenied` on a `git` that is not executable, and everything the + /// platform can invent — is a real error whose text is more useful than any + /// sentence written here would be. Mapping them all to [`Fetched::NoGit`] + /// would tell an operator with git installed to install git, and + /// `tests/fetch.rs` asserts that it does not. + #[must_use] + pub fn unstartable(error: &std::io::Error) -> Self { + if error.kind() == std::io::ErrorKind::NotFound { + return Fetched::NoGit; + } + Fetched::Failed { + status: None, + stderr: error.to_string(), + } + } + + /// One line for the operator, or `None` where there is nothing to say. + /// + /// `None` for [`Fetched::Cloned`] on purpose: the surface that asked for the + /// fetch says what it now has, and a success line written here as well would + /// be the same fact twice in two vocabularies. + /// + /// Assembled by pushing rather than by interpolation, which is the same + /// discipline the argv is held to and is asserted the same way — this file + /// builds no string out of a name with `format!`, anywhere, so there is one + /// rule to read rather than an exception to check. + #[must_use] + pub fn sentence(&self) -> Option { + match self { + Fetched::Cloned(_) => None, + Fetched::Already(here) => { + let mut said = String::from("that marketplace is already here: "); + said.push_str(&here.display().to_string()); + Some(said) + } + Fetched::NoGit => Some(String::from(NO_GIT)), + Fetched::Failed { status, stderr } => { + let mut said = String::from("git could not clone that"); + // The **last** non-blank line, because git narrates on stderr — + // `Cloning into '…'` first, then a `fatal:` that says what + // actually went wrong. Taking the first line would report the + // narration and hide the reason. + match stderr.lines().rev().find(|line| !line.trim().is_empty()) { + Some(reason) => { + said.push_str(": "); + said.push_str(reason.trim()); + } + None => { + // A silent failure still gets a number, because "git could + // not clone that" on its own is a sentence an operator can + // do nothing with. + if let Some(code) = status { + said.push_str(" (exited "); + said.push_str(&code.to_string()); + said.push(')'); + } + } + } + Some(said) + } + } + } +} + +/// What an operator typed, if it is an owner and a repository. +/// +/// **A pure function, and the only place a name is judged.** `None` means "that is +/// not a marketplace name" and never "that marketplace does not exist" — nothing +/// here touches the network or the disk, so this is answerable, and asserted, with +/// no fixture at all. +/// +/// Accepted: `owner/repo`, with a trailing `/` and a trailing `.git` taken off, +/// because both are what a paste leaves behind. Everything else is refused, +/// including a full URL — a URL has more than two segments and lands in the same +/// refusal, which is the outcome that matters: the only string that can reach +/// `git clone` is one this function built out of `HOST`, this module's own +/// constant. +/// +/// **Two of the refusals are the load-bearing ones.** A segment starting with `-` +/// would become an *option* the moment it were passed to a program that parses +/// options, whatever the argv looked like when it left here. A segment containing +/// `..` would leave the marketplaces directory the moment it were joined onto a +/// path. Neither is a hypothetical: both are what a name is for, if the person +/// choosing the name is choosing it against you. +#[must_use] +pub fn resolve(text: &str) -> Option { + let text = text.trim().trim_end_matches('/'); + let (owner, repo) = text.split_once('/')?; + let repo = repo.strip_suffix(".git").unwrap_or(repo); + // A third segment is a URL, a path, or a name for something this release does + // not have. Refused here rather than silently keeping the first two. + if repo.contains('/') { + return None; + } + if !segment(owner) || !segment(repo) { + return None; + } + Some(Named { + owner: owner.to_string(), + repo: repo.to_string(), + }) +} + +/// Whether one half of a name is a name at all. +/// +/// The permitted alphabet is what a forge permits and no more: letters, digits, +/// `-`, `_` and `.`. Stated as what is *allowed* rather than as a list of what is +/// forbidden, which is the only direction that stays correct when somebody thinks +/// of a character nobody here did. +fn segment(text: &str) -> bool { + !text.is_empty() + && !text.starts_with('-') + && !text.starts_with('.') + && !text.contains("..") + && text.chars().all(ordinary) +} + +/// Whether one character may appear in a name. +fn ordinary(glyph: char) -> bool { + glyph.is_ascii_alphanumeric() || glyph == '-' || glyph == '_' || glyph == '.' +} + +/// The repository a name points at. +/// +/// One element of the argv, built once, from a [`Named`] that [`resolve`] has +/// already judged — so there is no character in it that could mean anything to +/// anything downstream. +#[must_use] +pub fn url(named: &Named) -> String { + let mut url = String::from(HOST); + url.push_str(&named.owner); + url.push('/'); + url.push_str(&named.repo); + url.push_str(".git"); + url +} + +/// Where a marketplace lives once it is here. +/// +/// Two levels — `/` — rather than one flattened name, so two owners +/// may carry a repository of the same name and neither has to be qualified on +/// disk. `None` where the operator's home directory cannot be determined, which is +/// the answer [`crate::home::path`] gives for the same reason. +#[must_use] +pub fn at(named: &Named) -> Option { + Some(crate::home::marketplaces()?.join(&named.owner).join(&named.repo)) +} + +/// The argv, as five owned elements. +/// +/// **This is the property F10 is about, and it is a function so that it can be +/// asserted rather than described.** `url` and `into` are handed through as whole +/// elements — not quoted, not escaped, not joined to anything, not spliced into a +/// line for something else to split again. There is no shell between here and the +/// program, so a space, a quote or a semicolon in either is a space, a quote or a +/// semicolon in one argument and cannot be anything else. +/// +/// `OsString` rather than `String` because a destination is a path, and a path on +/// neither unix nor Windows is guaranteed to be UTF-8. Converting it to a `String` +/// to build an argument would be the first step of exactly the interpolation this +/// function exists to make impossible. +#[must_use] +pub fn argv(url: &str, into: &Path) -> Vec { + vec![ + OsString::from("clone"), + // Shallow, which is the whole of the bound: a marketplace is read for the + // directories in it, and its history is somebody else's. + OsString::from("--depth"), + OsString::from("1"), + OsString::from(url), + into.as_os_str().to_os_string(), + ] +} + +/// Clone `url` into `into`, assembling it at `staging` first. +/// +/// Both paths are the caller's, which is what lets `tests/fetch.rs` drive every +/// ending of this function against a temporary directory and a local repository — +/// a decision that lives in the driver is a decision nothing under `tests/` can +/// reach, and this crate has shipped that mistake before. +/// +/// The order is the property: an existing destination is answered **before** +/// anything is spawned, so a marketplace an operator already has is never cloned +/// over and never even risked; the staging directory is cleared before the clone, +/// because a previous run that was killed may have left one; and it is cleared +/// again afterwards whatever happened, so the only thing that can survive this +/// call is a complete tree at `into`. +pub fn clone(url: &str, into: &Path, staging: &Path) -> Fetched { + if into.exists() { + return Fetched::Already(into.to_path_buf()); + } + + // Best effort, and deliberately not fatal on its own: git creates the leading + // directories of its destination itself, and if it cannot, its own error is a + // better sentence than one invented here. + if let Some(parent) = staging.parent() { + let _ = crate::home::create(parent); + } + // Whatever a killed run left. git refuses a destination that exists and is not + // empty, so this is not tidiness — without it the second attempt after a crash + // fails for a reason that has nothing to do with the marketplace. + let _ = std::fs::remove_dir_all(staging); + + // All three streams are spelled out even though `output()` already wires them + // this way. The wiring is the property that keeps the viewport safe — the + // child gets no tty and writes nothing to the screen — and a reader checking + // that should not have to know a default. + let output = Command::new(PROGRAM) + .args(argv(url, staging)) + .env(NO_PROMPT, "0") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output(); + + let fetched = match output { + Err(error) => Fetched::unstartable(&error), + Ok(output) if output.status.success() => match settle(staging, into) { + Ok(()) => Fetched::Cloned(into.to_path_buf()), + // A clone that worked and a move that did not is still a failure with + // nothing at the destination, which is the only shape this module + // promises. The reason is the filesystem's own words. + Err(error) => Fetched::Failed { + status: None, + stderr: error.to_string(), + }, + }, + Ok(output) => Fetched::Failed { + status: output.status.code(), + stderr: readable(&output.stderr), + }, + }; + + // Unconditional, and a no-op on the path that succeeded — the rename already + // took the directory away. On every other path this is what makes the promise + // true for the failures this process lives to see; the rename is what makes it + // true for the ones it does not. + let _ = std::fs::remove_dir_all(staging); + fetched +} + +/// Move a finished clone into place. +/// +/// A rename, and the reason it can be one is that both paths are inside the +/// operator's own home: there is no cross-device case to fall back from, so unlike +/// [`crate::home`]'s own mover this needs no copy-and-verify path. The +/// destination's parent is created first, because a rename onto a path whose +/// parent does not exist fails on every platform. +fn settle(staging: &Path, into: &Path) -> std::io::Result<()> { + if let Some(parent) = into.parent() { + crate::home::create(parent)?; + } + std::fs::rename(staging, into) +} + +/// Captured stderr, made safe to put in a terminal. +/// +/// Text and only text: the control characters go, for the reason +/// [`Fetched::Failed`]'s field documents. Line structure is kept, because +/// [`Fetched::sentence`] reads the last line and a single squashed string has no +/// last line. +fn readable(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes) + .lines() + .map(|line| line.chars().filter(|glyph| !glyph.is_control()).collect()) + .collect::>() + .join("\n") +} + +/// Fetch the marketplace `named` into the operator's own home. +/// +/// `None` where the home cannot be located — the answer [`crate::home::path`] +/// gives, for the reason it gives it: a program that invents a path when it has no +/// home writes into somebody else's. +/// +/// **The staging directory carries this process's own id, and that is not +/// belt-and-braces.** io-cli's session lock is keyed on a conversation rather than +/// on the home (`src/lock.rs`), so two terminals in two repositories are two +/// processes that contend over nothing and share one `~/.io-cli`. A fixed staging +/// path would have them assembling two clones in one directory, and the one that +/// finished second would find the first one's tree. +pub fn fetch(named: &Named) -> Option { + let into = at(named)?; + let staging = crate::home::staging()?.join(std::process::id().to_string()); + Some(clone(&url(named), &into, &staging)) +} diff --git a/src/home.rs b/src/home.rs index 05df373..8dfbbcf 100644 --- a/src/home.rs +++ b/src/home.rs @@ -39,6 +39,34 @@ const FILE: &str = "io.toml"; /// only way an operator finds out where to put a skill without reading a document. const SKILLS: &str = "skills"; +/// The marketplaces directory, created with the home for the reason [`SKILLS`] is. +/// +/// A marketplace is a git repository the operator named, cloned to +/// `/marketplaces//` — two levels, so two owners may carry a +/// repository of the same name and neither has to be qualified on disk. +/// +/// Created here as well as by [`crate::fetch`], which has to make it anyway +/// because [`adopt`] does nothing at all for an operator who named their own +/// location. What creating it here buys is the same thing `skills/` buys: an +/// operator who opens their own home finds the directory before they have added +/// anything to it, which is how they learn where marketplaces go without reading a +/// document. +const MARKETPLACES: &str = "marketplaces"; + +/// Where a clone is assembled before it becomes a marketplace. +/// +/// **Dot-named, and deliberately outside [`MARKETPLACES`].** What is in here is by +/// definition unfinished: a process killed in the middle of a clone leaves a +/// directory holding part of somebody's repository, and if that directory sat +/// under `marketplaces/` it would be walked as an `` and its contents +/// counted as bundles. Outside it, nothing walks it and the worst a kill can leave +/// is wasted disk that the next fetch removes. +/// +/// Not created by [`adopt`]: it exists only while a fetch is in flight, and a home +/// carrying an empty `.fetching` would be one more thing for an operator to +/// wonder about — the same argument that keeps [`MEMORY`] out of [`adopt`]. +const STAGING: &str = ".fetching"; + /// The operator's own guidance file, beside the configuration file it belongs to. /// /// Named here rather than in [`crate::memory`] because the name is a fact about @@ -175,6 +203,34 @@ pub fn path() -> Option { Some(operator()?.join(DIR)) } +/// Where marketplaces are cloned to, whether or not the directory exists yet. +/// +/// Derived from [`path`] rather than from [`in_force`], the way +/// [`crate::contract::skills_dir`] derives the default skills directory +/// (`src/contract.rs:528`): a marketplace is io-cli's own cache of other people's +/// repositories, and an operator who pointed `$IO_CONFIG_HOME` somewhere else +/// moved their *configuration*. Following that variable here would put the clones +/// wherever the configuration is and leave the ones already fetched invisible. +/// +/// **The directory is not promised to exist.** [`adopt`] returns `None` without +/// creating anything whenever either variable is set, so every caller creates it +/// rather than assuming — which [`crate::fetch::clone`] does on the way past. +#[must_use] +pub fn marketplaces() -> Option { + Some(path()?.join(MARKETPLACES)) +} + +/// Where a clone is assembled before it is renamed into [`marketplaces`]. +/// +/// Same home, so the rename that finishes a fetch is a same-filesystem rename with +/// no cross-device case to fall back from — which is the whole reason this is here +/// rather than in the platform's temporary directory, where `/tmp` on a Linux box +/// is routinely a different filesystem from `$HOME`. +#[must_use] +pub fn staging() -> Option { + Some(path()?.join(STAGING)) +} + /// What the operator named, before anything of io-cli's own is set. /// /// [`adopt`] decides on this and nothing else: a variable that is there at all is @@ -272,6 +328,10 @@ pub fn adopt() -> Option { // is a home with no skills in it, while a home that could not be created at // all is a product with nowhere to live. let _ = create(&home.join(SKILLS)); + // The same best-effort, non-fatal shape, and for the same reason: a home + // without a marketplaces directory is a home nobody has added a marketplace + // to yet, and `crate::fetch` makes it when somebody does. + let _ = create(&home.join(MARKETPLACES)); let mut report = Report { home: home.clone(), diff --git a/src/lib.rs b/src/lib.rs index 23d843c..313d36b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,6 +40,14 @@ //! never asked. [`approval::git_allowance`] is the single rule that lifts it, and //! it names one binary. //! +//! Since 0.29.0 a capability bundle can come from somewhere other than a path the +//! operator typed. [`fetch`] resolves a name to a repository and clones it into +//! the operator's own home, and it is the **only module besides [`shell`] that +//! runs a program at all** — the harness owns git and publishes no way to run one +//! of its subcommands, so there is no governed call to reach for and the +//! alternative would have been an HTTP client. `tests/dependencies.rs` permits the +//! two by exact path and holds each to properties it asserts rather than trusts. +//! //! [io-harness]: https://docs.rs/io-harness pub mod app; @@ -65,6 +73,7 @@ pub mod exec; pub mod export; pub mod failure; pub mod fanout; +pub mod fetch; pub mod fleet; pub mod fuzzy; pub mod gates; diff --git a/tests/dependencies.rs b/tests/dependencies.rs index 5e22583..a698136 100644 --- a/tests/dependencies.rs +++ b/tests/dependencies.rs @@ -507,11 +507,30 @@ fn f7_no_source_file_loops_over_provider_responses() { ); } -/// The one module 0.7.0 permits a spawn in. A **path**, not a name: a second -/// `shell.rs` nested somewhere else in the tree would be a second spawn, which is -/// exactly what is being prevented. -fn spawning_module() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("src/shell.rs") +/// The modules permitted a spawn: `src/shell.rs` since 0.7.0, `src/fetch.rs` +/// since 0.29.0. A **set of exact paths**, and both halves of that are load +/// bearing. +/// +/// **Paths, not names**, because a second `shell.rs` nested somewhere else in the +/// tree would be a second spawn, which is exactly what 0.7.0 was preventing. +/// +/// **Exact, compared as a set — never a substring or a `contains` over the path's +/// text**, which is the second sabotage F11 names. `src/fetching/anything.rs` +/// matches no entry in this list and is refused; a gate written as +/// `path.to_string_lossy().contains("fetch")` would admit it and go on passing +/// while a third module spawned. The whole value of a permission list is that +/// widening it is an edit somebody makes on purpose, and a substring match is a +/// list that widens itself. +/// +/// **Sorted here rather than at every call site.** `read_dir` hands entries back +/// in whatever order the filesystem holds them, the lists compared against this +/// one are sorted, and a test that passes on one machine and fails on another is +/// worse than no test. +fn spawning_modules() -> Vec { + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut permitted = vec![src.join("fetch.rs"), src.join("shell.rs")]; + permitted.sort(); + permitted } #[test] @@ -530,8 +549,27 @@ fn f7_no_source_file_runs_a_command_or_touches_the_network() { // That distinction is the whole argument, and it is why one permission does // not reopen the rest. // - // The network half is permitted nowhere at all, this module included. - let permitted = spawning_module(); + // **0.29.0 amends this gate rather than relaxing it, and permits a second + // module: `src/fetch.rs`.** io-harness owns git and publishes no way to run a + // subcommand of it — `Git`, `Git::run` and `GitCmd` are `pub(crate)` there, + // and its own tests assert no argv it builds can carry `clone` — so a + // marketplace cannot be brought down through the dependency, and the only + // alternative to a spawn is an HTTP client with a TLS stack under it and a + // second network path beside io-harness's. That is a bigger hole in N1 than + // one more permitted path, and it is the trade the release record argues. + // + // The permission is a boundary rather than a hole, and the boundary is + // asserted twice below: `f10_the_fetch_spawns_git_and_builds_no_argument_out_ + // of_a_string` holds the new module to the four properties F10 names, and + // `f5_the_spawn_is_unreachable_from_the_event_path` holds BOTH permitted + // modules to naming nothing the event stream carries. What is not relaxed by + // any of it: the aliased spellings below stay forbidden in every file, this + // one included, and a THIRD file naming the literal still fails — naming both + // permitted paths when it does, so the reader is told what the list is rather + // than left to find it. + // + // The network half is permitted nowhere at all, these modules included. + let permitted = spawning_modules(); let mut spawns = Vec::new(); for (path, text) in sources() { @@ -575,17 +613,101 @@ fn f7_no_source_file_runs_a_command_or_touches_the_network() { spawns.sort(); assert_eq!( spawns, - vec![permitted], - "a process spawn appears somewhere other than src/shell.rs. There is one \ - module permitted to run a command and it is the one the release record \ - argues for; anywhere else is a tool implementation that no policy governs \ - and no trace records.", + permitted, + "a process spawn appears somewhere other than the two modules the release \ + records argue for. Exactly these are permitted, by exact path: \ + {permitted:?} — one for the operator's own `!` line, one for the `git \ + clone` that brings a marketplace down. Anywhere else is a tool \ + implementation that no policy governs and no trace records.", + ); +} + +/// **F10 — the four properties that keep 0.29.0's spawn exemption a boundary.** +/// +/// The path in `spawning_modules` is the permission; this is what the permission +/// is *for*, asserted over the module's own text so that a later edit which +/// quietly widens it has to go red here. +/// +/// 1. **One spawn, and its program is the literal `git`.** Asserted as a count and +/// as the constant, because either alone is escapable: a second +/// `Command::new` would be a second program with no argument about it, and a +/// program read from a variable is how a spawn of git becomes a spawn of +/// whatever the variable held. +/// 2. **No shell.** Nothing in the file names one, so there is no line for a +/// metacharacter in a repository name to be interpreted by. +/// 3. **No argument is built out of a string.** No `format!` anywhere in the +/// module's code — the sabotage F10 names is building the argv by interpolation +/// from the repository name, and this is the assertion it dies on. Paired with +/// the structural half: the argv reaches the spawn as the vector the pure +/// `argv` function returned, in one `.args(…)`, so there is no per-argument +/// builder call where an interpolation could hide and nothing that runs +/// differs from what `tests/fetch.rs` asserts. +/// 4. **It names nothing the event stream carries** — asserted for both permitted +/// modules in one loop by `f5_the_spawn_is_unreachable_from_the_event_path`, +/// which is where the same property already lives for `src/shell.rs`. +/// +/// Read against `code_of`, so the module is free to explain itself in prose. A +/// gate that reads comments forbids a file from naming what it deliberately does +/// not do, and this repository has now paid for that in 0.16.0, 0.19.0 and twice +/// in one wave in 0.25.0. +#[test] +fn f10_the_fetch_spawns_git_and_builds_no_argument_out_of_a_string() { + let module = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/fetch.rs"); + let text = std::fs::read_to_string(&module) + .expect("src/fetch.rs exists; 0.29.0's spawn exemption is written for it"); + let code = code_of(&text); + + assert_eq!( + code.matches("Command::new(").count(), + 1, + "src/fetch.rs spawns more than once. The exemption is for a `git clone` and \ + a second spawn is a second argument nobody has made.", + ); + assert!( + code.contains("Command::new(PROGRAM)"), + "src/fetch.rs spawns something that is not the module's own constant. A \ + program that comes from a variable is a spawn of whatever that variable \ + held, and the permission was for git.", + ); + assert!( + code.contains("pub const PROGRAM: &str = \"git\";"), + "the program constant is no longer the literal `git`, so the assertion \ + above is watching a name that means something else", + ); + + for shellish in ["/bin/sh", "cmd.exe", "COMSPEC", "SHELL", "powershell", "bash"] { + assert!( + !code.contains(shellish), + "src/fetch.rs names `{shellish}`. The fetch runs one program directly; a \ + shell between it and git would put every character of a repository name \ + back into a grammar.", + ); + } + + assert!( + !code.contains("format!"), + "src/fetch.rs builds a string with `format!`. F10's sabotage is exactly \ + this — an argv element interpolated from the repository name — and the \ + rule is written as an absence rather than as a review of each call so \ + that there is nothing to argue about at the next edit.", + ); + assert!( + !code.contains(".arg("), + "src/fetch.rs adds arguments one at a time. Every element goes through the \ + pure `argv` function, in one `.args(…)`, so what runs is what \ + tests/fetch.rs asserts — a per-argument builder is where an interpolation \ + hides from both.", + ); + assert!( + code.contains(".args(argv("), + "the spawn no longer takes its arguments from `argv`, so tests/fetch.rs is \ + asserting the shape of a function nothing runs", ); } /// F5 — and the half that a file list cannot state. /// -/// Permitting the spawn in one module is the first half of the argument. The +/// Permitting the spawn in a named module is the first half of the argument. The /// second is that nothing io-harness drives can reach it: the day a `RunEvent` /// handler can run a command is the day this crate has written a tool and the /// harness's policy has a hole in it, and that day would arrive without the test @@ -597,10 +719,13 @@ fn f7_no_source_file_runs_a_command_or_touches_the_network() { /// /// Three facts, and together they are the reachability argument: /// -/// 1. **The spawning module names nothing the event stream carries.** No +/// 1. **Neither spawning module names anything the event stream carries.** No /// `RunEvent`, no `EventKind`, no `Observer` — and no `Session` or `Store` /// either, which is also what keeps a `!` line's output out of the trace. So -/// it cannot itself *be* an event handler, whoever calls it. +/// neither can itself *be* an event handler, whoever calls it. **Asserted over +/// both permitted paths since 0.29.0**, in one loop rather than a copy: the +/// property is what makes a spawn exemption survivable and a second module +/// that was held to less would be the hole the first one was not. /// 2. **Nothing but the driver mentions the module.** The event path is /// `bridge::Observer` → `App::event` → `Events::event` → `diff::cell`, and /// none of those files may spell a call into it. Aliasing the module is the @@ -612,33 +737,44 @@ fn f7_no_source_file_runs_a_command_or_touches_the_network() { /// io-harness drives can produce one, so nothing io-harness drives can reach /// the spawn. Counted with its opening parenthesis, which is what a /// construction and a pattern have in common and a doc link does not. +/// +/// **Facts 2 and 3 are about `src/shell.rs` and stay about `src/shell.rs`.** The +/// module 0.29.0 adds has no reachability argument of this shape to make yet — +/// nothing calls it, and when something does it will be a command surface rather +/// than the driver — so this test admits its callers by not asserting over them, +/// and says so here rather than leaving a reader to infer it from a needle. What +/// it does NOT do is widen the shell's own rule: `shell::` is still spelled in one +/// file, and that file is still the driver. #[test] fn f5_the_spawn_is_unreachable_from_the_event_path() { - let permitted = spawning_module(); - let shell = std::fs::read_to_string(&permitted).expect("the spawning module"); - - for name in ["RunEvent", "EventKind", "Observer", "Session", "Store"] { + for module in spawning_modules() { + let text = std::fs::read_to_string(&module).expect("a spawning module"); + for name in ["RunEvent", "EventKind", "Observer", "Session", "Store"] { + assert!( + !text.contains(name), + "{} names {name}. A module that can see the event stream, the \ + conversation or the trace is a module the agent can reach — or one \ + that can write something the agent did not do into a record of what \ + it did.", + module.display(), + ); + } assert!( - !shell.contains(name), - "src/shell.rs names {name}. A module that can see the event stream, the \ - conversation or the trace is a module the agent can reach — or one that \ - can write the operator's own keystroke into a record of what the agent \ - did.", + text.contains("std::process::Command"), + "{} is a module permitted to spawn, so it is a module that has to spell \ + the spawn in full — otherwise the gate above is watching a string that \ + is no longer there", + module.display(), ); } - assert!( - shell.contains("std::process::Command"), - "src/shell.rs is the module permitted to spawn, so it is the module that has \ - to spell the spawn in full — otherwise the gate above is watching a string \ - that is no longer there", - ); + let interactive = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/shell.rs"); let driver = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs"); let app = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/app.rs"); let mut callers = Vec::new(); let mut builders = Vec::new(); for (path, text) in sources() { - if path == permitted { + if path == interactive { continue; } if text.contains("shell::") || text.contains("use crate::shell") { diff --git a/tests/fetch.rs b/tests/fetch.rs new file mode 100644 index 0000000..73139a1 --- /dev/null +++ b/tests/fetch.rs @@ -0,0 +1,391 @@ +//! F10 — the fetch spawns `git` and nothing else, and says so when it cannot. +//! +//! Four things are under test here and they fail for four different reasons, which +//! is the point of writing them apart: what an operator is allowed to name, what +//! that name becomes, what argv the spawn actually receives, and what each way of +//! failing leaves behind on the disk. +//! +//! **The two tests that spawn are deliberate, and they need no network.** A clone +//! from a local directory that is not a repository is a real non-zero exit with +//! real stderr; a clone from a directory `git init` just made is a real success +//! with a real tree to rename. Between them they exercise the whole of +//! `fetch::clone` — the spawn, the exit-code branch, the rename, and the removal +//! of the staging directory on both paths — without asking anything of a network +//! that a CI runner may not have. What they do ask for is `git` on the machine +//! running the suite, and they say so by name when it is missing rather than +//! quietly passing: a test that skips itself is a test that reports green for a +//! path nobody ran, and this product has shipped a vacuous gate in three separate +//! releases. +//! +//! **Nothing here mutates the process environment.** Every path a test needs is +//! passed in, because `fetch::clone` takes its destination and its staging +//! directory as arguments rather than reading the home — a decision that lived in +//! the driver would be a decision no test could reach. + +use std::ffi::OsString; +use std::path::Path; + +use io_cli::fetch::{self, Fetched, Named}; + +/// A name the tests use throughout, spelled once. +fn named() -> Named { + Named { + owner: "zeroonething".to_string(), + repo: "ultraship".to_string(), + } +} + +/// Run a real `git` with `args`, and fail by name on a machine that has none. +/// +/// Only the fixtures use this. The code under test builds its own argv, and a +/// helper that built it too would be a second answer to the question this file +/// exists to ask. +fn git(args: &[&str]) { + let ran = std::process::Command::new("git") + .args(args) + .output() + .unwrap_or_else(|error| { + panic!( + "this test needs `git` on PATH to build its fixture, and starting it \ + failed: {error}. That is the assumption product.yaml:253-258 records \ + about developer machines; it is not something this suite can work \ + around." + ) + }); + assert!( + ran.status.success(), + "the fixture command `git {}` failed: {}", + args.join(" "), + String::from_utf8_lossy(&ran.stderr), + ); +} + +/// **A marketplace is named by an owner and a repository, and by nothing else.** +/// +/// Fails when the parse widens: a URL, a bare word, a third segment or an empty +/// half accepted here is a string reaching `git clone` that nobody wrote a rule +/// for. The two paste-shaped inputs — a trailing `/` and a trailing `.git` — are +/// asserted to land on the *same* `Named` as the plain spelling rather than merely +/// to be accepted, so a parse that kept `.git` on the repository name (and so put +/// it into the destination path) fails here rather than in a directory listing. +#[test] +fn f10_a_marketplace_is_named_by_an_owner_and_a_repository() { + for spelling in [ + "zeroonething/ultraship", + " zeroonething/ultraship ", + "zeroonething/ultraship/", + "zeroonething/ultraship.git", + "zeroonething/ultraship.git/", + ] { + assert_eq!( + fetch::resolve(spelling), + Some(named()), + "`{spelling}` should name the same marketplace as the plain spelling", + ); + } + + for refused in [ + "", + " ", + "ultraship", + "/", + "/ultraship", + "zeroonething/", + "zeroonething/ultraship/extra", + "https://github.com/zeroonething/ultraship", + "git@github.com:zeroonething/ultraship.git", + "zeroonething ultraship", + "zero onething/ultraship", + ] { + assert_eq!( + fetch::resolve(refused), + None, + "`{refused}` is not an owner and a repository and must not be treated as \ + one — a URL in particular, because the only host this release can reach \ + is the one `fetch::url` writes", + ); + } +} + +/// **A name that could become an argument, or leave the directory, is refused.** +/// +/// Separated from the shape test above because these are the refusals with a +/// consequence rather than a tidiness: a leading `-` is an option to any program +/// that parses options, and `..` is a path component that walks out of the +/// marketplaces directory and into the operator's home. Both are what a name is +/// for, if whoever chose the name chose it against you. +/// +/// Fails the moment `segment` is loosened — including by the plausible-looking +/// edit of allowing "anything without a slash". +#[test] +fn f10_a_name_that_could_become_an_argument_or_leave_the_directory_is_refused() { + for hostile in [ + "-upload-pack=touch;/ultraship", + "zeroonething/-x", + "../ultraship", + "zeroonething/..", + ".ssh/ultraship", + "zeroonething/.ssh", + "zeroonething/ultra..ship", + "zeroonething/ultra ship", + "zeroonething/ultra\\ship", + "zeroonething/ultra;ship", + "zeroonething/ultra$ship", + "zeroonething/ultra\nship", + ] { + assert_eq!( + fetch::resolve(hostile), + None, + "`{hostile}` must not resolve: it would become an option, a path \ + component outside the marketplaces directory, or both", + ); + } +} + +/// **The clone URL is built from the name and from nothing else.** +/// +/// A whole-string equality rather than a `contains`, because every part of it is +/// load bearing: the scheme, the one host this release reaches, the order of the +/// two halves, and the `.git` that avoids a redirect. Fails if any of them moves. +#[test] +fn f10_the_clone_url_is_built_from_the_name_and_nothing_else() { + assert_eq!( + fetch::url(&named()), + "https://github.com/zeroonething/ultraship.git", + ); +} + +/// **The program is the literal `git` and the argv is five owned elements.** +/// +/// The centre of F10. The URL and the destination go in as a `&str` and a `&Path` +/// and are asserted to come out as *whole elements, byte for byte* — so a version +/// that quoted them, escaped them, joined them with a space, or built a single +/// command line out of them fails here, and fails whichever of those it did. +/// +/// The destination deliberately holds a space and a quote. A path like that is +/// ordinary on macOS and Windows, it is exactly what a shell would take apart, and +/// asserting on a tidy path would be asserting that nothing had gone wrong in the +/// only case where something could. +#[test] +fn f10_the_program_is_the_literal_git_and_the_argv_is_five_owned_elements() { + assert_eq!(fetch::PROGRAM, "git"); + + let url = fetch::url(&named()); + let into = Path::new("/tmp/an operator's home/marketplaces/zeroonething/ultraship"); + let argv = fetch::argv(&url, into); + + assert_eq!( + argv, + vec![ + OsString::from("clone"), + OsString::from("--depth"), + OsString::from("1"), + OsString::from("https://github.com/zeroonething/ultraship.git"), + OsString::from("/tmp/an operator's home/marketplaces/zeroonething/ultraship"), + ], + "the argv is `clone --depth 1 `, five elements, each one whole", + ); + + // Said again as two direct identities, because the vector comparison above + // would also be satisfied by a rewrite that happened to produce the same + // bytes, and what is being asserted is that these two inputs are *passed + // through*. + assert_eq!(argv[3], OsString::from(url)); + assert_eq!(argv[4], OsString::from(into)); + + // And no element is a pair. A `--depth 1` collapsed into one element, or a URL + // and a directory joined into a command line, would still be four or five + // elements on some other spelling — this is the assertion that says none of + // them contains a second argument. + assert!( + !argv.iter().any(|element| element + .to_string_lossy() + .contains("clone --depth")), + "two arguments have been joined into one element: {argv:?}", + ); +} + +/// **A marketplace lands two levels under the home, and the repository keeps its +/// own name.** +/// +/// Fails on the two plausible layouts that are wrong: flattening `/` +/// into one directory name, and carrying the `.git` suffix into the path. Both +/// would work for a single fetch and both break the listing that walks the tree. +#[test] +fn f10_a_marketplace_lands_two_levels_under_the_home() { + let root = io_cli::home::marketplaces() + .expect("a home directory; every platform this ships on sets one"); + assert_eq!( + fetch::at(&named()), + Some(root.join("zeroonething").join("ultraship")), + ); +} + +/// **An existing marketplace is never cloned over, and no clone is even started.** +/// +/// The destination may hold a marketplace an operator has already installed +/// plugins from, with a `[[plugin]] path` in their configuration pointing inside +/// it. So the assertion is not only the outcome: the marker file has to survive, +/// and the staging directory must never have been created — which is what says the +/// existence check happens *before* the spawn rather than after it. +#[test] +fn f10_an_existing_marketplace_is_not_cloned_over() { + let home = tempfile::tempdir().expect("a temporary directory"); + let into = home.path().join("marketplaces/zeroonething/ultraship"); + let staging = home.path().join(".fetching/1"); + std::fs::create_dir_all(&into).expect("the destination"); + let marker = into.join("plugin.toml"); + std::fs::write(&marker, b"name = \"ultraship\"\n").expect("the marker"); + + let fetched = fetch::clone(&fetch::url(&named()), &into, &staging); + + assert_eq!(fetched, Fetched::Already(into.clone())); + assert_eq!( + std::fs::read(&marker).expect("the marker survives"), + b"name = \"ultraship\"\n", + "the existing marketplace was written over", + ); + assert!( + !staging.exists(), + "a clone was started for a destination that already existed", + ); + assert!( + fetched + .sentence() + .expect("an outcome the operator is told about") + .contains("already"), + "the operator is told the marketplace is already here", + ); +} + +/// **A `git` that is not on `PATH` is a sentence, and every other failure is +/// not.** +/// +/// Both halves matter. Without the first, a machine with no git gets an +/// `io::Error`'s own words — `No such file or directory (os error 2)` — which +/// names neither git nor what to do. Without the second, an operator whose git is +/// present but not executable is told to install the thing they already have: +/// mapping every spawn error to "no git" is the plausible simplification, and this +/// is the assertion that refuses it. +/// +/// Driven through the mapping rather than by emptying `PATH`, because a test +/// process that mutates its own environment mutates it for every other test in the +/// binary. +#[test] +fn f10_a_git_that_is_not_on_path_is_a_sentence_and_every_other_failure_is_not() { + let missing = Fetched::unstartable(&std::io::Error::from(std::io::ErrorKind::NotFound)); + assert_eq!(missing, Fetched::NoGit); + + let said = missing.sentence().expect("a sentence, not a stack trace"); + assert!( + said.contains("git") && said.contains("PATH"), + "the sentence names neither git nor where it was looked for: {said}", + ); + assert!( + said.contains("directory"), + "the sentence should say that installing a plugin from a directory still \ + works, or a machine without git reads as a machine io-cli does not run \ + on: {said}", + ); + + let refused = Fetched::unstartable(&std::io::Error::from(std::io::ErrorKind::PermissionDenied)); + assert_ne!( + refused, + Fetched::NoGit, + "a git that cannot be executed is not a git that is not there, and telling \ + that operator to install git is telling them to do what they have done", + ); + assert!( + refused + .sentence() + .expect("a sentence") + .contains("permission"), + "the platform's own words are more use here than any sentence written in \ + this crate", + ); +} + +/// **A clone that fails leaves nothing at the destination and no staging +/// directory, and carries git's own stderr.** +/// +/// A real spawn against a directory that is not a repository: a real non-zero +/// exit, real stderr, no network. What it asserts is the non-functional criterion +/// in full — *a failure at any point leaves no half-cloned directory presented as +/// a marketplace* — plus the reason reaching the operator, which is git's sentence +/// and not a paraphrase of it. +#[test] +fn f10_a_clone_that_fails_leaves_nothing_at_the_destination_and_no_staging() { + let home = tempfile::tempdir().expect("a temporary directory"); + let absent = home.path().join("not-a-repository"); + let into = home.path().join("marketplaces/zeroonething/ultraship"); + let staging = home.path().join(".fetching/1"); + + let fetched = fetch::clone(&absent.to_string_lossy(), &into, &staging); + + let Fetched::Failed { status, stderr } = &fetched else { + panic!( + "cloning a path that is not a repository should fail, and be seen to: \ + {fetched:?}. A `NoGit` here means this machine has no `git`, which the \ + fixture helper in this file explains." + ); + }; + assert_ne!(*status, Some(0), "a failure with a successful exit code"); + assert!( + !stderr.trim().is_empty(), + "git's own reason was dropped, so the operator is told only that something \ + went wrong", + ); + assert!( + !into.exists(), + "a failed clone left something at the destination, where the listing would \ + count it as a marketplace", + ); + assert!(!staging.exists(), "the staging directory was left behind"); + assert!( + fetched + .sentence() + .expect("a sentence") + .starts_with("git could not clone that"), + "the failure is reported in io-cli's voice with git's reason inside it", + ); +} + +/// **A clone that works is renamed into place, and the staging directory goes.** +/// +/// The success path, end to end, against a repository `git init` made a moment +/// ago. `git clone` of an empty repository exits zero and writes a real `.git`, so +/// this exercises the spawn, the rename and the cleanup without a network and +/// without a commit — and it is the only test that can catch a rename that never +/// happened, which would otherwise show up as a marketplace nobody could find. +#[test] +fn f10_a_clone_that_works_is_renamed_into_place_and_the_staging_goes() { + let home = tempfile::tempdir().expect("a temporary directory"); + let source = home.path().join("source"); + std::fs::create_dir_all(&source).expect("the source directory"); + git(&["init", "--quiet", &source.to_string_lossy()]); + + let into = home.path().join("marketplaces/zeroonething/ultraship"); + let staging = home.path().join(".fetching/1"); + + let fetched = fetch::clone(&source.to_string_lossy(), &into, &staging); + + assert_eq!( + fetched, + Fetched::Cloned(into.clone()), + "a clone that git completed should be reported as one", + ); + assert!( + into.join(".git").is_dir(), + "the clone is not at the destination, so the rename did not happen", + ); + assert!( + !staging.exists(), + "the staging directory survived a successful fetch", + ); + assert_eq!( + fetched.sentence(), + None, + "a fetch that worked has nothing to say; the surface that asked for it says \ + what it now has", + ); +} From 66f4ebb49a9111a2c3a040e6d07209791b4292ca Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 17:44:28 +0530 Subject: [PATCH 05/12] test(deps): give F11's substring sabotage a site, and fix two doc claims it falsified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The sabotage had nowhere to run.** F11's second arm is "widen the permitted set to a substring match rather than a path set". A widening like that makes the gate more permissive, so nothing fails — the gate goes vacuous without going red, and the call site cannot observe it because the call site's own list is the thing being widened. The rustdoc named the hazard and no test held it. The comparison is now `only_the_permitted_spawn`, and `f11_the_permitted_spawn_set_is_exact_paths_and_never_a_substring` hands it four near-miss paths — sharing a stem, sharing a prefix, sitting under a directory named for a permitted stem, and the same shape against the other module — plus a control asserting the permitted set admits itself, so a predicate answering false for everything cannot satisfy it. Rewrite the comparison as any `contains`, `starts_with` or stem match and the test goes red naming the file it admitted. This product shipped a gate that could not fail in 0.25.0 and again in 0.27.0. 0.28.0 recorded the rule both broke: enumerate the arms and check each has a site before trusting the set. Two doc claims went stale in the release that amended the gate, which is when they get fixed rather than later. `src/repo.rs` said the spawn gate permits `src/shell.rs` alone and was "unamended by this release"; `src/lock.rs` said a spawn is permitted only in one module. Neither broke a gate, because neither spells the literal that gate matches on. `cargo fmt --all` over the wave — subagent-written code is not fmt-clean and `cargo fmt --check` is a CI gate. --- src/exec.rs | 7 ++-- src/fetch.rs | 6 ++- src/lock.rs | 8 ++-- src/repo.rs | 8 +++- tests/dependencies.rs | 92 ++++++++++++++++++++++++++++++++++++++++--- tests/fetch.rs | 6 +-- tests/gates.rs | 4 +- 7 files changed, 110 insertions(+), 21 deletions(-) diff --git a/src/exec.rs b/src/exec.rs index 06dc526..cf80f16 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -201,9 +201,10 @@ pub fn describe(outcome: &RunOutcome) -> String { RunOutcome::Success { steps } => ("finished, and its verification passed", steps), RunOutcome::Finished { steps } => ("finished", steps), RunOutcome::StepCapReached { steps } => ("stopped at the step cap", steps), - RunOutcome::VerificationFailed { steps } => { - ("stopped at the step cap, and its verification failed", steps) - } + RunOutcome::VerificationFailed { steps } => ( + "stopped at the step cap, and its verification failed", + steps, + ), RunOutcome::TimeBudgetExceeded { steps } => ("stopped at the time budget", steps), RunOutcome::CostBudgetExceeded { steps } => ("stopped at the token budget", steps), RunOutcome::BudgetCeilingReached { steps } => { diff --git a/src/fetch.rs b/src/fetch.rs index a3daf35..256274b 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -320,7 +320,11 @@ pub fn url(named: &Named) -> String { /// the answer [`crate::home::path`] gives for the same reason. #[must_use] pub fn at(named: &Named) -> Option { - Some(crate::home::marketplaces()?.join(&named.owner).join(&named.repo)) + Some( + crate::home::marketplaces()? + .join(&named.owner) + .join(&named.repo), + ) } /// The argv, as five owned elements. diff --git a/src/lock.rs b/src/lock.rs index d8a55d8..bdbb426 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -42,9 +42,11 @@ //! //! **Naming the holder cannot mean naming the operating system's process.** //! `tests/dependencies.rs` asserts the direct dependency set in both directions -//! and forbids `nix` outright, and a process spawn is permitted only in -//! `crate::shell`, which may not name a `Store` and is callable only from the -//! driver. (Spelled that way round deliberately: that gate sweeps the raw text +//! and forbids `nix` outright, and a process spawn is permitted only in the two +//! modules that gate names by exact path — `crate::shell`, which may not name a +//! `Store` and is callable only from the driver, and from 0.29.0 `crate::fetch`, +//! which may not name one either and knows nothing of a session. Neither is this +//! module. (Spelled that way round deliberately: that gate sweeps the raw text //! of every source file for the type's full name, comments included, so a module //! that wrote it out to say it was *not* spawning would be listed as a module //! that spawns.) So there is no `kill(pid, 0)`, no `ps` and no `tasklist`, and diff --git a/src/repo.rs b/src/repo.rs index 612f9b4..074dbb5 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -9,8 +9,12 @@ //! in 0.24.0. //! //! **Nothing here spawns anything.** `tests/dependencies.rs` permits a process -//! spawn in `src/shell.rs` alone, identified by path, and that assertion is -//! unamended by this release. That gate matches raw text, comments included, so +//! spawn in two modules, identified by exact path — `src/shell.rs` since 0.7.0 +//! for the operator's own `!` line, and `src/fetch.rs` since 0.29.0 for the `git +//! clone` that brings a marketplace down — and this module is neither of them. +//! (That sentence said "`src/shell.rs` alone, unamended by this release" until +//! 0.29.0, which amended it. A doc claim about a gate goes stale in the release +//! that changes the gate, not later.) That gate matches raw text, comments included, so //! the forbidden name is not spelled out here either — not even to say this module //! does not use it, which is a sentence that reddens the gate it is describing. //! A branch name does not need a subprocess anyway: git writes it into `.git/HEAD` diff --git a/tests/dependencies.rs b/tests/dependencies.rs index a698136..c85b8bd 100644 --- a/tests/dependencies.rs +++ b/tests/dependencies.rs @@ -611,17 +611,90 @@ fn f7_no_source_file_runs_a_command_or_touches_the_network() { // them, so every list this file compares is sorted first. A test that passes // on one machine and fails on another is worse than no test. spawns.sort(); - assert_eq!( - spawns, - permitted, + assert!( + only_the_permitted_spawn(&spawns), "a process spawn appears somewhere other than the two modules the release \ - records argue for. Exactly these are permitted, by exact path: \ - {permitted:?} — one for the operator's own `!` line, one for the `git \ + records argue for. Found {spawns:?}; exactly these are permitted, by exact \ + path: {permitted:?} — one for the operator's own `!` line, one for the `git \ clone` that brings a marketplace down. Anywhere else is a tool \ implementation that no policy governs and no trace records.", ); } +/// Is `found` **exactly** the permitted set? +/// +/// A named predicate rather than an `assert_eq!` inline above, and the reason is +/// the only reason: **F11's second sabotage needs somewhere to run.** That arm is +/// "widen the permitted set to a substring match rather than a path set", and a +/// widening like that makes this file *more* permissive — so nothing fails, and +/// the gate goes vacuous without going red. There is no way to observe it from +/// the call site, because the call site's own list is the thing being widened. +/// +/// With the comparison named, `f11_the_permitted_spawn_set_is_exact_paths…` can +/// hand it a near-miss set and watch it refuse. Rewrite this as +/// `found.iter().all(|p| permitted.iter().any(|q| p.starts_with(q)))`, or as any +/// `contains` over the path text, and that test goes red naming the file it just +/// admitted. +/// +/// This product has shipped a gate that could not fail in 0.25.0 and again in +/// 0.27.0, and 0.28.0 recorded the rule it broke both times: enumerate the arms +/// and check each has a site before trusting the set. +fn only_the_permitted_spawn(found: &[PathBuf]) -> bool { + found == spawning_modules().as_slice() +} + +/// **F11 — the permitted set is exact paths, and widening it is an edit somebody +/// makes on purpose.** +/// +/// The sibling arm of F11 — a third file naming the spawn — is already covered: +/// `f7_no_source_file_runs_a_command_or_touches_the_network` sweeps `src/` and +/// compares what it finds against the list. This one covers the arm that sweep +/// cannot see, because a gate that has been widened refuses nothing and therefore +/// fails nothing. +/// +/// Each near-miss below shares a prefix, a stem or a parent with a permitted path +/// and is a different file. Under the exact comparison every one is refused; under +/// any substring, `starts_with` or stem match, at least one is admitted and this +/// test fails naming it. +#[test] +fn f11_the_permitted_spawn_set_is_exact_paths_and_never_a_substring() { + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let permitted = spawning_modules(); + + // The control. Without it a predicate that answered `false` for everything + // would satisfy every assertion below while refusing the real modules too. + assert!( + only_the_permitted_spawn(&permitted), + "the permitted set does not admit itself, so nothing below means anything", + ); + + for near in [ + // Shares the stem `fetch`, which is what a `contains` would match on. + src.join("fetching.rs"), + // Shares the whole stem as a prefix, which is what `starts_with` on the + // text would match on. + src.join("fetch_marketplace.rs"), + // A module directory whose parent is the permitted path's stem — what + // `starts_with` on the *path* would match on. + src.join("fetch").join("mod.rs"), + // And the same three shapes against the other permitted module, so the + // property is asserted for both rather than for the new one only. + src.join("shell_out.rs"), + ] { + let mut widened = permitted.clone(); + widened.push(near.clone()); + widened.sort(); + assert!( + !only_the_permitted_spawn(&widened), + "{} is admitted beside the permitted modules. The set is compared by \ + exact path for exactly this reason: a substring, stem or prefix match \ + is a permission list that widens itself, and a third module would then \ + spawn while this file went on passing.", + near.display(), + ); + } +} + /// **F10 — the four properties that keep 0.29.0's spawn exemption a boundary.** /// /// The path in `spawning_modules` is the permission; this is what the permission @@ -675,7 +748,14 @@ fn f10_the_fetch_spawns_git_and_builds_no_argument_out_of_a_string() { above is watching a name that means something else", ); - for shellish in ["/bin/sh", "cmd.exe", "COMSPEC", "SHELL", "powershell", "bash"] { + for shellish in [ + "/bin/sh", + "cmd.exe", + "COMSPEC", + "SHELL", + "powershell", + "bash", + ] { assert!( !code.contains(shellish), "src/fetch.rs names `{shellish}`. The fetch runs one program directly; a \ diff --git a/tests/fetch.rs b/tests/fetch.rs index 73139a1..5a308a4 100644 --- a/tests/fetch.rs +++ b/tests/fetch.rs @@ -198,9 +198,9 @@ fn f10_the_program_is_the_literal_git_and_the_argv_is_five_owned_elements() { // elements on some other spelling — this is the assertion that says none of // them contains a second argument. assert!( - !argv.iter().any(|element| element - .to_string_lossy() - .contains("clone --depth")), + !argv + .iter() + .any(|element| element.to_string_lossy().contains("clone --depth")), "two arguments have been joined into one element: {argv:?}", ); } diff --git a/tests/gates.rs b/tests/gates.rs index 3584f72..8c5e3ba 100644 --- a/tests/gates.rs +++ b/tests/gates.rs @@ -838,9 +838,7 @@ fn the_gate_retry_admits_a_run_that_failed_its_verification() { let text = text.replace("\r\n", "\n"); assert!( - text.contains( - "io_cli::exec::OK | io_cli::exec::CEILING | io_cli::exec::UNVERIFIED" - ), + text.contains("io_cli::exec::OK | io_cli::exec::CEILING | io_cli::exec::UNVERIFIED"), "the gate driver admits only the outcomes that mean the run reached its \ own end, and since io-harness 0.70.0 a run that failed its criterion at \ the step cap is one of them — `RunOutcome::VerificationFailed`, which \ From 414704a6c1fb444f93b246a32e654d59bba60b0d Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 18:18:43 +0530 Subject: [PATCH 06/12] feat(marketplace): add, list and remove a marketplace, through one parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin can now come from somewhere other than a directory the operator already had. `/plugin marketplace add /` and `io plugin marketplace add …` reach one parse, one judged `Named`, and one clone under `~/.io-cli/marketplaces//`. A bundle inside it is any directory carrying a `plugin.toml`, so there is no index to write and none to disagree with the directories it describes. No new command. `Configure` stands at nine of ten and is the product's last free slot; marketplace is verbs inside `/plugin`, which is the shape 0.28.0 established. `COMMANDS.len()` is still 36 and the occupancy is still 7/9/10/10. `marketplace::declared` reads `name` and `description` through `edit::value_at` and unquotes them the way `pluginview::declared_at` does, so `src/edit.rs` is still the only file in this crate permitted to parse TOML. A directory whose manifest does not name itself is still a bundle: it is listed under its directory name and says the manifest did not name it, rather than being dropped silently. All three verbs `plan` to `Ok(None)`. An add or a remove is a change to the disk and not to any file io-harness layers, so there is no scope to choose and no entry to spell. An empty `Plan` would make `configure::write` create a file and report a write that never happened, and a second member on `Plan` would be the second write path that module forbids. That is also F3: no edit is ever built for a removal, so a removal cannot take a declaration with it. `marketplace::remove` has three doors and `add` has two, which is not an oversight. The panel's "add a marketplace" row prefills the composer, because a name is free text with nothing to choose from, so it arrives back through the typed door; the panel's remove acts on a marketplace the operator picked, after a `store::LEAVE_IT` confirmation, so it is a real third door. The gate asserting this was written expecting symmetry and said two for both — corrected, with the doors named, rather than the count merely raised. Every public item in `src/fetch.rs` now has a production call site. It had none when it landed, which is the shape this product has shipped five times. --- README.md | 2 +- src/commands.rs | 34 ++- src/lib.rs | 8 + src/main.rs | 413 +++++++++++++++++++++++-- src/manage.rs | 147 ++++++++- src/marketplace.rs | 628 ++++++++++++++++++++++++++++++++++++++ tests/marketplace.rs | 701 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1901 insertions(+), 32 deletions(-) create mode 100644 src/marketplace.rs create mode 100644 tests/marketplace.rs diff --git a/README.md b/README.md index 12c5a0c..a8150d0 100644 --- a/README.md +++ b/README.md @@ -574,7 +574,7 @@ above a row that ranked there for reasons having nothing to do with it. | `/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 | | `/provider` | the providers configured, in the order a turn tries them | -| `/plugin` | the capability bundles loaded, what each contributed, and the ones that failed | +| `/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 | | `/import` | bring instructions, MCP servers, skills and a model across from another agent | diff --git a/src/commands.rs b/src/commands.rs index 96aff72..27cefcd 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -221,15 +221,16 @@ pub const COMMANDS: &[(&str, &str)] = &[ // which is the argument for it being visible at all, and the argument for it // being under "configure" rather than "inspect". // - // **What it does not do is add one, and the description says "loaded" rather - // than implying otherwise.** Declaring a bundle means naming a directory, - // which is a path an operator types far more comfortably into their own file - // than into a picker; removing one means finding the right entry among three - // scope files, which is exactly the part a surface can do better than a - // person. So this does the second and not the first. + // **0.29.0 adds where a bundle comes from, and it is a verb inside this + // command rather than a command of its own.** `Configure` is nine of ten and + // the product has one free slot; a marketplace is not a tenth surface, it is + // the repository the bundles on this surface were fetched from, so it belongs + // under the list it feeds. `/plugin marketplace add|list|remove` is read by + // `manage::parse` — the same parse `io plugin marketplace …` goes through — + // and the picker offers the same three verbs without anything being typed. ( "/plugin", - "the capability bundles loaded, what each contributed, and the ones that failed", + "the capability bundles declared, the marketplaces they come from, and what failed", ), ( "/import", @@ -1234,6 +1235,15 @@ pub enum Action { /// declared in the project file that contributes `[[hook]]` or `[[mcp]]` is /// refused **whole**, and the sentence is what names the two files it could /// move to instead. + /// + /// **Since 0.29.0 the panel also offers the marketplaces bundles come from.** + /// That is one extra row on this surface — the verbs live behind it, under + /// their own picker — and the same three verbs are typed as + /// `/plugin marketplace add|list|remove`, which routes to [`Action::Manage`] + /// and is therefore read by the same parse `io plugin marketplace …` goes + /// through. Nothing about a marketplace is written into a configuration file: + /// adding one clones a repository into `~/.io-cli/marketplaces` and removing + /// one deletes that clone, leaving every `[[plugin]]` entry exactly as it was. Plugin, /// Bring an operator's work across from another agent they already use. /// @@ -1962,8 +1972,16 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { // `/plugins` is admitted for the same reason `/servers` and `/providers` // are: the thing being listed is plural, so the plural is what a hand // reaches for, and refusing it teaches nothing. + // `marketplace` is here beside `add` and `remove` because it is the same + // kind of word: a verb with arguments, read by `manage::parse` and by + // nothing else. A bare `/plugin marketplace` reaches the parse too and is + // refused there by name — which is better than opening the bundle list + // over a line that asked for something else. "plugin" | "plugins" - if matches!(input.split_whitespace().nth(1), Some("add" | "remove")) => + if matches!( + input.split_whitespace().nth(1), + Some("add" | "remove" | "marketplace") + ) => { Action::Manage(input.to_string()) } diff --git a/src/lib.rs b/src/lib.rs index 313d36b..6fc39b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -48,6 +48,13 @@ //! alternative would have been an HTTP client. `tests/dependencies.rs` permits the //! two by exact path and holds each to properties it asserts rather than trusts. //! +//! Everything that happens once a clone is on the disk is [`marketplace`] and not +//! [`fetch`], deliberately: `src/fetch.rs` is held to properties asserted over its +//! own text, so listing a marketplace, reading the two keys of a `plugin.toml` and +//! deleting a directory — none of which needs a program — are kept outside that +//! boundary. Both are driven from one place, [`manage`], so `/plugin marketplace +//! add …` and `io plugin marketplace add …` are one reading of one sentence. +//! //! [io-harness]: https://docs.rs/io-harness pub mod app; @@ -85,6 +92,7 @@ pub mod keys; pub mod lock; pub mod manage; pub mod markdown; +pub mod marketplace; pub mod memory; pub mod page; pub mod picker; diff --git a/src/main.rs b/src/main.rs index f572f9d..6dcb995 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1838,11 +1838,14 @@ async fn loop_over Result>( } } } - // The add verb, and the one row on this surface that is not - // a bundle. It is checked first because `add_at` is past - // the end of both lists and every branch below indexes into - // one of them. - Pick::Plugins { view: _, add_at } if index == *add_at => { + // The two verb rows, and the only rows on this surface that + // are not bundles. They are checked first because both sit + // past the end of both lists and every branch below indexes + // into one of them. Each is compared against **its own** + // recorded index: neither is derived from the other, so a + // row inserted between them cannot make one of these arms + // answer for the other's row. + Pick::Plugins { add_at, .. } if index == *add_at => { let found = io_cli::pluginview::candidates(session.root()); if found.is_empty() { // **Naming where it looked.** "No bundles found" is @@ -1882,6 +1885,19 @@ async fn loop_over Result>( )); } } + // The marketplaces, behind their own row rather than mixed + // into the list above: a marketplace is not a bundle, and a + // list an operator chooses a bundle out of must not have + // rows in it that are something else. `/plugin marketplace + // list` opens this same surface through `Action::Manage`, + // built by the same function, so the keystroke and the typed + // line cannot draw two different lists. + Pick::Plugins { market_at, .. } if index == *market_at => { + match marketplaces_picker(screen.width(), &app.theme.glyphs) { + Ok(surface) => descended = Some(surface), + Err(refusal) => app.record(Tone::Refused, refusal), + } + } // Row 0 is "leave it", so the candidate's own position is // one less — bound to its own name rather than to `index`, // which is how a confirmation acts on the wrong row. @@ -1928,7 +1944,7 @@ async fn loop_over Result>( } } } - Pick::Plugins { view, add_at: _ } => { + Pick::Plugins { view, .. } => { // **Answered from the reading the rows were drawn // from, and `/skills` does the opposite on purpose.** // There, a row names a file the operator may have @@ -1941,7 +1957,9 @@ async fn loop_over Result>( // // The split is `pluginview::rows`'s own: `view.plugins` // first — loaded then switched off — refused after, no - // headings, so the index is direct. + // headings, so the index is direct. The two verb rows + // past the end of both lists are answered by the guarded + // arms above and never reach here. if let Some(plugin) = view.plugins.get(index) { // `descended`, not `picker`: the assignment at the // end of this match installs it, while `kind` @@ -2011,6 +2029,143 @@ async fn loop_over Result>( ); } } + // The add row, and the one place in this release that asks + // for free text. A marketplace is named `/` + // and there is no list to choose one from — nothing on this + // machine knows what exists on a forge — so the composer is + // prefilled with the verb and the operator types the name, + // which is `Pick::McpEdit`'s own shape and the same one + // surface this product takes free text through. The line + // then goes back through `manage::parse`, so a name typed + // here is judged by the function that judges every other. + Pick::Marketplaces { add_at, .. } if index == *add_at => { + app.record( + Tone::Muted, + "a marketplace is a GitHub repository of capability bundles; \ + type its `/` after the verb and press Enter" + .to_string(), + ); + app.composer.set("/plugin marketplace add "); + } + // Index `i` is `markets[i]`, which is `marketplace::rows`' + // positional contract, and the add row above is the only + // row that is not one. + Pick::Marketplaces { markets, .. } => { + if let Some(market) = markets.get(index) { + if market.bundles.is_empty() { + // Said before the pane opens rather than drawn + // as a row in it: a placeholder row inside the + // list would be an index that maps to no + // bundle, which is the whole class of defect + // this surface is arranged against. + app.record( + Tone::Muted, + format!( + "no directory in {} carries a {}; it may be laid out \ + deeper than io walks, or it may not be a marketplace", + market.root.display(), + io_cli::pluginview::MANIFEST, + ), + ); + } + let mut rows = io_cli::marketplace::bundle_rows( + market, + screen.width(), + &app.theme.glyphs, + ); + // Taken before the row is pushed. See + // `Pick::Marketplace`. + let remove_at = rows.len(); + rows.push(Row::with_detail( + "remove this marketplace".to_string(), + "deletes the clone; no `[[plugin]]` entry is removed" + .to_string(), + )); + descended = Some(( + Picker::new(market.name(), rows), + Pick::Marketplace { + market: market.clone(), + remove_at, + }, + )); + } + } + Pick::Marketplace { market, remove_at } => { + if index == *remove_at { + // **What the removal costs, worked out before it is + // offered and never after.** A bundle declared + // straight out of this clone keeps its `[[plugin]]` + // entry — which is F3 — and stops loading, which is + // F3 read the other way round. Computed here rather + // than in the confirmation's own arm because the + // clone still exists at this point, so the entries + // inside it can still be found. + let inside = io_cli::marketplace::dependents( + &io_cli::pluginview::view(&config), + &market.root, + ); + // `record` and never `say`: the footer is one slot + // and is gone on the next keystroke, and this is a + // consequence the operator has to still be able to + // read after they have answered. + if let Some(said) = io_cli::marketplace::warning(&inside) { + app.record(Tone::Warning, said); + } + descended = Some(( + Picker::new( + format!("Remove {}?", market.name()), + vec![ + // Row 0 declines, and it is + // `store::LEAVE_IT` rather than a + // literal so that the label and the + // `store::acts` test of it cannot drift. + Row::new(io_cli::store::LEAVE_IT.to_string()), + Row::with_detail( + "delete the clone".to_string(), + format!( + "removes {}; every `[[plugin]]` entry is left \ + exactly as it is", + market.root.display() + ), + ), + ], + ), + Pick::MarketplaceRemove { + named: market.named.clone(), + }, + )); + } else if let Some(bundle) = market.bundles.get(index) { + app.record( + Tone::Muted, + format!( + "{} — {} · {}", + bundle.label(), + bundle.line(), + bundle.dir.display(), + ), + ); + // **Declared through the verb that already declares + // a bundle, not through a second writer.** A + // marketplace directory is a directory like any + // other, so `plugin add ` writes the entry — + // and it is the same parse, the same + // `pluginview::refusal` and the same + // `configure::write` every other declaration goes + // through. A path prefilled here is one the + // operator has just read on the row above it. + app.composer + .set(&format!("/plugin add {}", bundle.dir.display())); + } + } + Pick::MarketplaceRemove { named } => { + // `store::acts` rather than `index == 1`: the decision + // lives in the library, where a test can sabotage it, + // and row 0 declines whatever it is called. + if io_cli::store::acts(index) { + let outcome = io_cli::marketplace::remove(named); + app.record(tone_of(&outcome), outcome.said); + } + } Pick::Skills(drawn) => { // **Located by what the row said, not by where it // sat.** The list is read again — the operator may @@ -3545,8 +3700,9 @@ async fn loop_over Result>( // came rather than re-worded into a second opinion about // somebody else's rule. Err(refusal) => app.record(Tone::Refused, refusal), - // A reading verb reaching here can only be `mcp get`: the - // list verbs are routed to their own panels before `parse` + // A reading verb reaching here can only be `mcp get` or one + // of the marketplace verbs below: `/mcp`, `/plugin` and + // `/config` are routed to their own panels before `parse` // is asked, because a panel is a better answer in a session // than a text dump is. Answered from the configuration this // session is running on. @@ -3573,6 +3729,57 @@ async fn loop_over Result>( ), } } + // **The three verbs that change the disk and no file.** + // `plan` answers `None` for all of them — there is no + // scope, no `[[…]]` entry and no value to spell — so they + // are acted on here, through the *same* `marketplace` + // functions `marketplace_main` calls. Neither door + // resolves a name, chooses a path or writes a sentence of + // its own; what they differ in is where they print, which + // is all the two doors have ever differed in. + Ok(( + io_cli::manage::Request::Plugin( + io_cli::manage::PluginVerb::Marketplace(verb), + ), + None, + // By reference: every library call below takes a + // `&Named`, so nothing is moved out of the parsed verb + // and there is no clone to keep in step with it. + )) => match &verb { + // A panel rather than a text dump, which is what every + // other list verb typed into a session gets — and it is + // the panel the `/plugin` row opens, from one builder. + io_cli::manage::MarketVerb::List => { + match marketplaces_picker(screen.width(), &app.theme.glyphs) { + Ok(surface) => picker = Some(surface), + Err(refusal) => app.record(Tone::Refused, refusal), + } + } + io_cli::manage::MarketVerb::Add(named) => { + // **`record` and never `say`.** A fetch that failed + // carries git's own last line, and the footer is one + // slot that the next keystroke takes back — this + // product has shipped that defect twice. + let outcome = io_cli::marketplace::add(named); + app.record(tone_of(&outcome), outcome.said); + } + io_cli::manage::MarketVerb::Remove(named) => { + // Worked out while the clone is still there. See + // `Pick::Marketplace`, which asks the same question + // before it offers the same removal. + if let Some(clone) = io_cli::fetch::at(named) { + let inside = io_cli::marketplace::dependents( + &io_cli::pluginview::view(&config), + &clone, + ); + if let Some(warned) = io_cli::marketplace::warning(&inside) { + app.record(Tone::Warning, warned); + } + } + let outcome = io_cli::marketplace::remove(named); + app.record(tone_of(&outcome), outcome.said); + } + }, Ok((_, None)) => {} Ok((request, Some(plan))) => { match io_cli::configure::write(&root, plan.scope, &plan.edits) { @@ -3930,14 +4137,33 @@ async fn loop_over Result>( } let mut rows = io_cli::pluginview::rows(&view, screen.width(), &app.theme.glyphs); - // Taken before the row is pushed, never worked out afterwards. + // **Every index on this surface is taken as `rows.len()` + // immediately before its own row is pushed, and never worked + // out from another one.** The layout is now four ranges — + // the loaded and switched-off bundles, the refused ones, the + // add row, the marketplaces row — and 0.20.0 shipped a silent + // wrong delete through exactly the arithmetic that a fifth + // range would tempt somebody into writing here (`add_at + 1`). // See `Pick::Plugins`. let add_at = rows.len(); rows.push(Row::with_detail( "add a bundle".to_string(), "chooses a directory that carries a `plugin.toml`".to_string(), )); - picker = Some((Picker::new("Plugins", rows), Pick::Plugins { view, add_at })); + let market_at = rows.len(); + rows.push(Row::with_detail( + "marketplaces".to_string(), + "the repositories bundles are fetched from: add, list and remove" + .to_string(), + )); + picker = Some(( + Picker::new("Plugins", rows), + Pick::Plugins { + view, + add_at, + market_at, + }, + )); } // **Everything is shown before anything is written, and the // default for every item is no.** The plan is built whole here — @@ -7240,15 +7466,25 @@ enum Pick { /// detail pane — every accessor is valid on one, and the pane opens on the row /// that says none of it is in this session. /// - /// `add_at` is where the add row sits, recorded by the code that built the rows - /// rather than recomputed here — `pluginview::rows` draws the loaded bundles - /// and then the refused ones, so the only number that cannot be wrong is the - /// length of what was already there. This is [`Pick::PluginEntry`]'s - /// `action_at` rule, and it is the rule because every index in this surface - /// addresses a list somewhere else. + /// `add_at` and `market_at` are where the two verb rows sit, each recorded by + /// the code that built the rows rather than recomputed here — `pluginview::rows` + /// draws the loaded bundles and then the refused ones, so the only number that + /// cannot be wrong is the length of what was already there. This is + /// [`Pick::PluginEntry`]'s `action_at` rule, and it is the rule because every + /// index in this surface addresses a list somewhere else. + /// + /// **`market_at` is a field and not `add_at + 1`.** The two rows happen to be + /// adjacent today; the arithmetic that assumes it is the arithmetic that + /// shipped a wrong delete in 0.20.0, and it survives the next row inserted + /// between them by being wrong rather than by failing. + /// + /// The four ranges, in order: `view.plugins[i]` for `i < plugins.len()`; + /// `view.refused[i - plugins.len()]` up to `add_at`; `add_at` itself; then + /// `market_at`. Plugins { view: io_cli::pluginview::View, add_at: usize, + market_at: usize, }, /// The directories below the discovery root that carry a `plugin.toml`, with /// "leave it" at row 0. @@ -7260,6 +7496,39 @@ enum Pick { /// only way a wrong one arrives — a candidate can lose its manifest between the /// row being drawn and this keystroke. PluginAdd(Vec), + /// The marketplaces in `~/.io-cli/marketplaces`, in `marketplace::rows`' order. + /// + /// `add_at` is where the add row sits, taken from the length of the list's own + /// rows before it was pushed — the rule [`Pick::Plugins`] and + /// [`Pick::Provider`] both follow, and the rule because an index worked out + /// afterwards addresses a different list than the one on screen. + /// + /// The whole [`io_cli::marketplace::Market`] is carried rather than a name, + /// for [`Pick::Plugins`]' reason: descending has to show what the marketplace + /// holds, and re-walking the clone to answer a keystroke would be a second + /// reading of directories that can change between two of them. + Marketplaces { + markets: Vec, + add_at: usize, + }, + /// One marketplace's bundles, in `marketplace::bundle_rows`' order, and the + /// one verb offered on the marketplace itself. + /// + /// `remove_at` is past the end of `market.bundles`, taken before the row was + /// pushed. Every other index in this variant addresses that list. + Marketplace { + market: io_cli::marketplace::Market, + remove_at: usize, + }, + /// The confirmation that deletes one clone, with [`io_cli::store::LEAVE_IT`] + /// at row 0. + /// + /// The **name** is carried and never a row number or a path built here: the + /// removal resolves the destination through the same `/` layout + /// the fetch wrote, so the directory deleted is the directory added. + MarketplaceRemove { + named: io_cli::fetch::Named, + }, /// One setting's values, as rows, with "leave it" at row 0. /// /// **The descent that ends free text.** Until 0.28.0 choosing a `/config` row @@ -7435,6 +7704,13 @@ fn manage_main( eprintln!("{}: {}", refused.path.display(), refused.error); } } + // **Answered here and returned from, because none of the three plans an + // edit.** A marketplace is a clone on the disk rather than a line in a + // configuration file, so `manage::plan` says `None` for all three and the + // fall-through below would exit zero having done nothing at all. + io_cli::manage::Request::Plugin(io_cli::manage::PluginVerb::Marketplace(verb)) => { + return marketplace_main(config, verb); + } io_cli::manage::Request::Config(io_cli::manage::ConfigVerb::Get { key }) => { let setting = io_cli::configure::setting(config, key); println!( @@ -7480,6 +7756,109 @@ fn manage_main( Ok(io_cli::exec::OK) } +/// `io plugin marketplace add|list|remove` — the argument form. +/// +/// **Every decision is `crate::marketplace`'s and every name has already been +/// judged by `crate::manage`.** This function chooses no path, spells no refusal +/// and resolves no name: it prints what the library answered. That is F1's whole +/// property — the argv form has no branch of its own — and its named sabotage is +/// exactly the code this function refuses to contain. +/// +/// **`list` is the only verb that writes to stdout**, for the reason `manage_main` +/// gives: a listing is what a script reads, and the sentence an `add` or a `remove` +/// produces is prose about what happened. So the prose goes to stderr and the exit +/// status carries whether it happened, which is the same contract every other verb +/// in that function keeps. +fn marketplace_main( + config: &io_harness::config::Config, + verb: &io_cli::manage::MarketVerb, +) -> Result { + match verb { + io_cli::manage::MarketVerb::List => { + let markets = io_cli::marketplace::installed() + .ok_or_else(|| io_cli::marketplace::NOWHERE.to_string())?; + for market in &markets { + // The count of directories carrying a manifest, which is what a + // marketplace *is* to this product, beside the name it is removed + // by and the path it occupies. The bundles' own names and + // descriptions are what descending into one shows; a second row + // shape in one listing is a listing no script can read. + println!( + "{}\t{}\t{}", + market.name(), + market.bundles.len(), + market.root.display(), + ); + } + Ok(io_cli::exec::OK) + } + io_cli::manage::MarketVerb::Add(named) => answered(&io_cli::marketplace::add(named)), + io_cli::manage::MarketVerb::Remove(named) => { + // **Before the clone goes, because afterwards the entries pointing + // into it name nothing that can be found.** The entries themselves are + // never touched — that is F3 — so this is the only place the operator + // is told what stops loading. + if let Some(clone) = io_cli::fetch::at(named) { + let inside = + io_cli::marketplace::dependents(&io_cli::pluginview::view(config), &clone); + if let Some(warned) = io_cli::marketplace::warning(&inside) { + eprintln!("{warned}"); + } + } + answered(&io_cli::marketplace::remove(named)) + } + } +} + +/// One [`io_cli::marketplace::Outcome`], as the argv door's answer. +/// +/// A refusal is an `Err` and therefore a non-zero exit; the two endings that are +/// not refusals both exit zero, because "it is already here" is what the operator +/// asked for and a script must not have to tell it from a failure. +fn answered(outcome: &io_cli::marketplace::Outcome) -> Result { + if outcome.went == io_cli::marketplace::Went::Refused { + return Err(outcome.said.clone()); + } + eprintln!("{}", outcome.said); + Ok(io_cli::exec::OK) +} + +/// The marketplaces surface, built once for the two ways of reaching it. +/// +/// The `/plugin` panel's own row and a typed `/plugin marketplace list` both come +/// here, so the keystroke and the line cannot draw two different lists — the same +/// reason `manage::parse` is one function. +/// +/// `add_at` is taken from the length of the list's own rows before the add row is +/// pushed. See [`Pick::Marketplaces`]. +fn marketplaces_picker(width: u16, glyphs: &Glyphs) -> Result<(Picker, Pick), String> { + let markets = + io_cli::marketplace::installed().ok_or_else(|| io_cli::marketplace::NOWHERE.to_string())?; + let mut rows = io_cli::marketplace::rows(&markets, width, glyphs); + let add_at = rows.len(); + rows.push(Row::with_detail( + "add a marketplace".to_string(), + "clones `/` from GitHub into ~/.io-cli/marketplaces".to_string(), + )); + Ok(( + Picker::new("Marketplaces", rows), + Pick::Marketplaces { markets, add_at }, + )) +} + +/// The tone one of the three endings is drawn in. +/// +/// **`Already` is `Muted` and not `Refused`**, because a marketplace that is +/// already here is what the operator asked for; drawing it as a refusal is the +/// same conflation `Went` exists to prevent, told in colour. +fn tone_of(outcome: &io_cli::marketplace::Outcome) -> Tone { + match outcome.went { + io_cli::marketplace::Went::Acted => Tone::Success, + io_cli::marketplace::Went::Already => Tone::Muted, + io_cli::marketplace::Went::Refused => Tone::Refused, + } +} + /// The models `preset` actually serves, spelled the way `preset` spells them. /// /// **`verify::served` alone is the wrong list, and offering it raw was a defect diff --git a/src/manage.rs b/src/manage.rs index fcfb51a..bf53777 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -2,10 +2,20 @@ //! //! `/mcp add …` typed into a composer and `io mcp add …` typed at a shell are the //! same sentence arriving through two doors, and this module is the only room -//! behind both of them. Every verb this release adds — `mcp add|list|get|edit| -//! remove`, `plugin add|list|remove`, `config get|set|unset|list` — is turned into -//! a [`Request`] here and into [`crate::edit::Edit`]s by [`plan`], and neither -//! entry point is allowed a second reading of the same words. +//! behind both of them. Every verb these releases add — `mcp add|list|get|edit| +//! remove`, `plugin add|list|remove`, `plugin marketplace add|list|remove`, +//! `config get|set|unset|list` — is turned into a [`Request`] here and into +//! [`crate::edit::Edit`]s by [`plan`], and neither entry point is allowed a second +//! reading of the same words. +//! +//! **One of them is turned into no edit at all, and that is not an exception to +//! the rule above.** `plugin marketplace add|remove` changes the operator's disk +//! rather than their configuration — there is no scope, no `[[…]]` entry and no +//! value to spell — so [`plan`] answers `None` for it and the act itself is +//! [`crate::marketplace`], one function each door calls. The parse is still the +//! only reading of the words, which is what criterion F1 is about; what the two +//! doors then differ in is where they *print*, which is all they have ever +//! differed in. //! //! # Why the parse is not clap's //! @@ -115,6 +125,36 @@ pub enum PluginVerb { /// Undeclare a bundle. No scope: the file that named it is the file the /// removal has to go to, and [`plan`] finds it. Remove { path: PathBuf }, + /// The repositories bundles are fetched from. + /// + /// **A nested enum rather than three more variants here, and the argument is + /// the same one `pluginview` makes about a third list.** Every `match` on + /// this enum in the crate would otherwise grow three arms, and the next verb + /// added to either half is the one somebody forgets in one of them — the + /// `plan` arm below is a single line precisely because there is a single + /// variant to name. It also keeps the surface honest: a marketplace is + /// reached through `/plugin` because it holds plugins, not because it is a + /// fourth managed surface, and `Request` still has exactly three. + Marketplace(MarketVerb), +} + +/// What `/plugin marketplace` and `io plugin marketplace` can be asked to do. +/// +/// **Each verb carries a [`crate::fetch::Named`] rather than the text it was +/// typed as.** `fetch::resolve` is the only place in this crate a marketplace name +/// is judged — it is what refuses a leading `-`, a `..` and a whole URL — so +/// carrying the judged value means nothing downstream can re-read the operator's +/// string, and a name that reached [`plan`] or a driver unresolved would be a +/// second reading with a second opinion about what a name may contain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MarketVerb { + /// Clone it into the operator's own home. + Add(crate::fetch::Named), + /// Every marketplace already there, and what each holds. + List, + /// Delete the clone. **Never a `[[plugin]]` entry** — see + /// [`crate::marketplace::discard`], which is where criterion F3 lives. + Remove(crate::fetch::Named), } /// What `/config` and `io config` can be asked to do. @@ -255,6 +295,14 @@ pub fn parse(tokens: &[String]) -> Result { args.nothing("plugin list")?; Ok(Request::Plugin(PluginVerb::List)) } + // **Scanned from the sub-verb rather than from `args` above**, because + // `args` was sorted out of `tokens[2..]` and here `tokens[2]` is the verb + // itself: reading the name out of `args.positional[1]` would make + // `plugin marketplace list add` a listing with a stray word nobody + // refused. One extra `scan` is the price of the sub-verb having the same + // flag rules, the same `--` rule and the same refusals as every other + // verb in this module. + ("plugin", Some("marketplace")) => marketplace(tokens).map(Request::Plugin), ("plugin", Some("remove")) => { args.no_scope("plugin remove")?; args.only("plugin remove", &[])?; @@ -295,14 +343,87 @@ pub fn parse(tokens: &[String]) -> Result { } /// The verbs of one surface, for the sentence that refuses another word. +/// +/// **Updated in the same edit as the reader above, always.** A refusal listing +/// verbs the parse does not take, or omitting one it does, tells an operator at a +/// terminal that a verb does not exist — which costs them the trip to the +/// documentation this whole module's refusals exist to save. fn verbs(surface: &str) -> &'static str { match surface { "mcp" => "`add`, `list`, `get`, `edit` and `remove`", - "plugin" => "`add`, `list` and `remove`", + "plugin" => "`add`, `list`, `remove` and `marketplace`", _ => "`get`, `set`, `unset` and `list`", } } +/// The verbs `plugin marketplace` takes. Named once, for the same reason. +const MARKET_VERBS: &str = "`add `, `list` and `remove `"; + +/// `plugin marketplace (add | list | remove )`. +/// +/// The sub-verb is `tokens[2]` and its own words are `tokens[3..]`, scanned here +/// so that a flag, a `--` or a second positional is refused by the same three +/// helpers every other verb uses. None of the three takes a flag at all — +/// including `--scope`, which is refused by [`Args::only`] with the rest, because +/// a marketplace is not written into any configuration file and so has no scope to +/// choose. +fn marketplace(tokens: &[String]) -> Result { + let args = scan(tokens.get(3..).unwrap_or(&[]))?; + let verb = match tokens.get(2).map(String::as_str) { + Some("add") => { + args.only("plugin marketplace add", &[])?; + MarketVerb::Add(judged(&args.one_word( + "plugin marketplace add", + "the name of a marketplace, written `/`", + )?)?) + } + Some("list") => { + args.nothing("plugin marketplace list")?; + MarketVerb::List + } + Some("remove") => { + args.only("plugin marketplace remove", &[])?; + MarketVerb::Remove(judged(&args.one_word( + "plugin marketplace remove", + "the name of a marketplace, written `/`", + )?)?) + } + Some(unknown) => { + return Err(format!( + "`{unknown}` is not a verb `plugin marketplace` takes; it takes {MARKET_VERBS}" + )) + } + None => { + return Err(format!( + "`plugin marketplace` needs a verb after it; it takes {MARKET_VERBS}" + )) + } + }; + Ok(PluginVerb::Marketplace(verb)) +} + +/// A marketplace name, judged by the one function that judges them. +/// +/// [`crate::fetch::resolve`] is the whole rule — two ordinary path segments, no +/// leading dash, no `..`, no third segment — and it is deliberately not repeated +/// here. The refusal says what is accepted and *where it is fetched from*, because +/// the single-forge ceiling is a stated bound rather than an oversight and an +/// operator who pasted a URL from somewhere else has to be told which fact refused +/// them. +/// +/// Not called `named`: that name is already taken in this module by the flag-list +/// renderer, and two functions one letter apart in purpose is how the wrong one +/// gets called. +fn judged(text: &str) -> Result { + crate::fetch::resolve(text).ok_or_else(|| { + format!( + "`{text}` is not a marketplace name; a marketplace is a GitHub repository named \ + `/`, as in `plugin marketplace add zeroonething/ultraship` — a whole \ + URL, a local path and a name with a leading `-` or a `..` in it are all refused" + ) + }) +} + /// Turn a request into the file and the edits that carry it out. /// /// `Ok(None)` is a **read** — `list`, `get` — which writes nothing and is @@ -412,8 +533,22 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { // operator's entire block. See `Edit::unset`. edits: vec![Edit::unset(key.clone())], }, + // **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 + // reads — they change the disk — but they change *the disk* and not a + // configuration file: there is no scope to choose, no `[[…]]` entry to + // splice and no [`Edit`] that could express either. A `Plan` carrying an + // empty edit list is the thing `Ok(None)` exists to prevent (see this + // function's own docs: `configure::write` would create the file, discover + // the whole tree and report a write that never happened), and a second + // member on `Plan` for "run this instead" would be the second write path + // this module forbids. So the act lives in [`crate::marketplace`], which + // both doors call and neither reimplements, and `plan` answers what is + // true of it here: **nothing is written to any configuration file**. That + // is also exactly criterion F3 — removing a marketplace cannot touch a + // `[[plugin]]` entry, because this function never builds one. Request::Mcp(McpVerb::List | McpVerb::Get { .. }) - | Request::Plugin(PluginVerb::List) + | Request::Plugin(PluginVerb::List | PluginVerb::Marketplace(_)) | Request::Config(ConfigVerb::Get { .. } | ConfigVerb::List) => return Ok(None), }; Ok(Some(plan)) diff --git a/src/marketplace.rs b/src/marketplace.rs new file mode 100644 index 0000000..d046d1d --- /dev/null +++ b/src/marketplace.rs @@ -0,0 +1,628 @@ +//! The marketplaces on disk — what each one is, what it holds, and taking one +//! away. +//! +//! [`crate::fetch`] brings a repository down; this module is everything that +//! happens once it is here. The split is the one `src/fetch.rs`'s own docs argue +//! for: that file is the crate's second permitted process spawn and is held to +//! properties `tests/dependencies.rs` asserts over its text, so every line that +//! does *not* need to be inside that boundary is kept out of it. Listing a clone, +//! reading a manifest and deleting a directory need no program at all. +//! +//! # Everything here takes its paths as arguments +//! +//! `~/.io-cli/marketplaces` is reached in exactly three functions — [`add`], +//! [`remove`] and [`installed`] — and each is a wrapper of two or three lines over +//! something that takes the directory as an argument. That is `fetch::clone`'s own +//! arrangement and it is here for the same reason: a decision that lives behind +//! [`crate::home`] is a decision nothing under `tests/` can reach without moving +//! the operator's home out from under a suite running in parallel, and this crate +//! has shipped untestable driver logic in three releases and paid for it in the +//! release after each. +//! +//! # This module reads a `plugin.toml` and io-cli's other surfaces do not +//! +//! [`crate::pluginview`] deliberately opens no manifest: every fact it draws comes +//! from [`io_harness::Config::plugins`], because a second reader of what a manifest +//! *means* is a second opinion about somebody else's file. Nothing is different +//! here about that rule — what is different is the question. A bundle inside a +//! marketplace is **not declared by any configuration**, so `Config::plugins()` has +//! never heard of it and cannot be asked. The only thing that can answer *what is +//! in this repository* is the repository. +//! +//! So the reading is kept to the two keys a listing draws — `name` and +//! `description` — and it goes through [`crate::edit::value_at`], because +//! `src/edit.rs` is this crate's only permitted TOML parser by path +//! (`tests/dependencies.rs`, `f7_the_configuration_is_read_through_the_harness_…`). +//! That function answers in **source bytes**, quotes included, so the value is +//! unquoted here exactly the way `src/pluginview.rs:727` already unquotes a +//! declared path — one rule for the whole crate rather than two spellings of 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 +//! declares it; a listing that dropped the directory would be answering "what is in +//! this repository" with a filtered list and no mark saying so, which is the silent +//! absence `pluginview`'s module docs exist to end. It is listed under its +//! directory's own name, with [`Bundle::line`] saying the manifest did not name it. +//! +//! # Removing a marketplace removes a clone and nothing else +//! +//! [`discard`] deletes a directory. It does not touch a `[[plugin]]` entry, it +//! cannot reach one, and that is criterion F3 rather than an omission: an operator +//! who declared a bundle out of a marketplace made a decision about their +//! configuration, and a cache being emptied is not a reason to undo it. What the +//! surface owes them instead is the *consequence*, which is why [`dependents`] and +//! [`warning`] exist — a bundle declared at a path inside the clone stays declared +//! and stops loading, and being told that is the whole difference between a cache +//! eviction and a session that quietly lost its agents. + +use std::path::{Path, PathBuf}; + +use crate::fetch::{Fetched, Named}; +use crate::glyphs::Glyphs; +use crate::picker::{fit, fit_left, Row}; +use crate::pluginview::MANIFEST; + +/// The mark on a marketplace that is on the disk. +/// +/// [`crate::pluginview::LOADED_MARK`]'s own character and for its own reason: one +/// ASCII glyph in both sets, already meaning *present* everywhere else in this +/// product. A marketplace has no second state to distinguish it from, so it needs +/// no second mark — the mark is here so a marketplace row cannot be mistaken for a +/// bundle row when the two lists are read one after the other. +pub const HERE_MARK: &str = crate::pluginview::LOADED_MARK; + +/// One directory inside a marketplace that carries a [`MANIFEST`]. +/// +/// Owned, and the two fields that come out of the manifest are `Option` rather +/// than defaulted: "this manifest has no description" and "this manifest's +/// description is empty" are one fact to a reader and two to a writer, and the +/// listing says which. See the module docs for why a nameless manifest is here at +/// all. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bundle { + /// The directory the manifest sits in. + pub dir: PathBuf, + /// The manifest's `name`, unquoted, or `None` where it carries none this + /// module could read. + pub name: Option, + /// The manifest's `description`, unquoted, or `None`. + pub description: Option, +} + +impl Bundle { + /// What to call it: the manifest's `name`, and the directory's own name only + /// where the manifest gave none. + /// + /// **The manifest wins, and F2's sabotage is returning the directory + /// instead.** io-harness namespaces every contribution a bundle makes by the + /// manifest's `name` — a bundle in a directory called `plugins/reviewer` whose + /// manifest says `rust-review` contributes `rust-review__reviewer`, and a + /// listing that showed `reviewer` would have named nothing an operator will + /// ever see again. + #[must_use] + pub fn label(&self) -> String { + match (&self.name, self.dir.file_name()) { + (Some(name), _) => name.clone(), + (None, Some(dir)) => dir.to_string_lossy().into_owned(), + // A directory with no last component at all — a bare `/`, or a path + // ending in `..`. Neither can be a bundle, and a row drawn with an + // empty label is worse than one drawn with the whole path. + (None, None) => self.dir.display().to_string(), + } + } + + /// The one line beside the label: the description, or what is missing. + /// + /// A nameless manifest says so here rather than being drawn as an ordinary + /// row under a directory name, because the label is then io-cli's guess and + /// the operator has no other way to tell. + #[must_use] + pub fn line(&self) -> String { + match (&self.name, &self.description) { + (Some(_), Some(said)) => said.clone(), + (Some(_), None) => "its plugin.toml carries no description".to_string(), + (None, Some(said)) => { + let mut out = String::from("its plugin.toml does not name it; "); + out.push_str(said); + out + } + (None, None) => { + "its plugin.toml does not name it, and carries no description".to_string() + } + } + } +} + +/// One marketplace, as it is on the disk. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Market { + /// The `/` it was fetched by. The same value + /// [`crate::fetch::resolve`] produces, so the name a listing draws is the name + /// `plugin marketplace remove` takes. + pub named: Named, + /// The clone's own directory. + pub root: PathBuf, + /// Every directory in it that carries a [`MANIFEST`], the root itself + /// included. See [`holdings`]. + pub bundles: Vec, +} + +impl Market { + /// `/`, which is both the label and the word an operator types. + #[must_use] + pub fn name(&self) -> String { + let mut out = self.named.owner.clone(); + out.push('/'); + out.push_str(&self.named.repo); + out + } + + /// How many bundles it holds, said in words rather than as a bare number. + /// + /// **Zero is a real answer and gets its own sentence.** A marketplace whose + /// repository holds no `plugin.toml` at all is either the wrong repository or + /// one whose bundles live deeper than the walk goes, and "0 bundles" leaves an + /// operator unable to tell those apart from a fetch that failed. + #[must_use] + pub fn held(&self) -> String { + match self.bundles.len() { + 0 => format!("no directory in it carries a {MANIFEST}"), + 1 => "1 bundle".to_string(), + many => format!("{many} bundles"), + } + } +} + +/// The clone directory of `named` under a marketplaces directory. +/// +/// Two levels — `/` — which is [`crate::fetch::at`]'s own layout with +/// the home passed in rather than looked up. The two are asserted equal in +/// `tests/marketplace.rs`: a second spelling of the destination is how an `add` +/// writes to one path and a `remove` looks in another. +#[must_use] +pub fn at(root: &Path, named: &Named) -> PathBuf { + root.join(&named.owner).join(&named.repo) +} + +/// One manifest's two listed keys, or `None` where `dir` carries no manifest. +/// +/// The existence check is the same one [`crate::pluginview::refusal`] makes and it +/// is what decides whether a directory is a bundle at all. Everything after it is +/// best effort: an unreadable or unparseable manifest yields a [`Bundle`] with +/// neither key, which [`Bundle::line`] reports as a manifest that does not name it +/// — the honest answer, and the one io-harness will give in its own words if the +/// operator goes on to declare it. +#[must_use] +pub fn manifest(dir: &Path) -> Option { + let file = dir.join(MANIFEST); + if !file.is_file() { + return None; + } + let text = std::fs::read_to_string(&file).unwrap_or_default(); + Some(Bundle { + dir: dir.to_path_buf(), + name: declared(&text, "name"), + description: declared(&text, "description"), + }) +} + +/// One top-level key of a manifest, unquoted. +/// +/// [`crate::edit::value_at`] answers in the value's **source bytes**, so a string +/// arrives with its quotes on. They are trimmed the way `src/pluginview.rs:727` +/// trims a declared path — the same two calls in the same order, so there is one +/// rule in this crate for turning TOML source back into a value rather than two +/// that can drift. +/// +/// An empty result is `None` rather than `Some("")`: a key present and blank names +/// a bundle no better than a key that is absent, and collapsing them here means +/// [`Bundle::label`] has one case to answer instead of two. +fn declared(text: &str, key: &str) -> Option { + let raw = crate::edit::value_at(text, key)?; + let value = raw.trim().trim_matches('"').trim(); + if value.is_empty() { + return None; + } + Some(value.to_string()) +} + +/// Every bundle inside a clone, the clone's own root included. +/// +/// **The walk is [`crate::pluginview::candidates`]'s**, not a second one written +/// here: it already skips `target`, `node_modules` and every dotted directory — +/// `.git`, which every clone has, most of all — and it already orders by depth and +/// then by path so two calls on one machine answer the same way. A repository +/// laying its bundles out in a way that walk cannot see is a repository `/plugin +/// add` could not see either, and one walk that is sometimes too shallow is better +/// than two that disagree about which. +/// +/// The root is checked separately because that function only ever looks at a +/// directory's *children*, and a marketplace that is itself one bundle — a single +/// plugin published as its own repository — is the shape it would otherwise miss +/// entirely. +#[must_use] +pub fn holdings(clone: &Path) -> Vec { + let mut dirs = Vec::new(); + if clone.join(MANIFEST).is_file() { + dirs.push(clone.to_path_buf()); + } + dirs.extend(crate::pluginview::candidates(clone)); + dirs.iter().filter_map(|dir| manifest(dir)).collect() +} + +/// Every marketplace under `root`, ordered by owner and then by repository. +/// +/// Exactly two levels, because that is the layout [`crate::fetch::at`] writes and +/// nothing else in that tree is a marketplace. A deeper walk would find the +/// bundles of every clone and report each as a marketplace of its own; a shallower +/// one would report an owner. Anything that is not a directory, and anything whose +/// name starts with a dot, is skipped at both levels — `.fetching` is a sibling of +/// this tree rather than inside it, but a dot-named leftover of any kind is not +/// something an operator asked for and must never be counted as one. +#[must_use] +pub fn markets(root: &Path) -> Vec { + let mut found = Vec::new(); + for owner in ordinary(root) { + for repo in ordinary(&owner) { + // Built out of the two directory names rather than out of the path, so + // the value carried is the same shape `fetch::resolve` produces and a + // listing row can be handed straight back to `remove`. + let (Some(owner_name), Some(repo_name)) = (name_of(&owner), name_of(&repo)) else { + continue; + }; + found.push(Market { + named: Named { + owner: owner_name, + repo: repo_name, + }, + bundles: holdings(&repo), + root: repo, + }); + } + } + found.sort_by(|a, b| (&a.named.owner, &a.named.repo).cmp(&(&b.named.owner, &b.named.repo))); + found +} + +/// The plain directories directly inside `dir`, unsorted. +fn ordinary(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .filter(|path| !name_of(path).is_some_and(|name| name.starts_with('.'))) + .collect() +} + +/// A path's last component as a `String`, or `None` where it has none. +fn name_of(path: &Path) -> Option { + Some(path.file_name()?.to_string_lossy().into_owned()) +} + +/// What io-cli says when it has no home of its own to keep a marketplace in. +/// +/// A constant because two surfaces say it: the act itself, and the panel that has +/// no list to draw. [`crate::home::path`]'s own shape — a program that invents a +/// path when it has no home writes into somebody else's — as a sentence rather +/// than a panic or a silent no-op. +pub const NOWHERE: &str = + "io has no home directory of its own, so there is nowhere to keep a marketplace"; + +/// How an act ended. +/// +/// **Three endings and not a `bool`, because the two that did not change the disk +/// are not the same thing to either door.** A marketplace that was already here is +/// a fine thing to have asked for — the argv form must exit zero and the session +/// form must not draw a refusal over it — while a machine with no git, a clone git +/// rejected and a name that is not here are all failures an operator has to act +/// on. Folding them together is how a script stops being able to tell "it is +/// there" from "it could not be fetched", which is [`crate::fetch::Fetched`]'s own +/// argument one layer down for keeping `NoGit` and `Failed` apart. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Went { + /// The disk changed. + Acted, + /// Nothing changed and nothing is wrong. + Already, + /// Nothing changed and something is. + Refused, +} + +/// What an act did, and the one line the operator is owed for it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Outcome { + /// Which of the three endings it was. The argv door's exit status and the + /// session door's tone are both this and nothing else, so the two cannot + /// disagree about whether something happened. + pub went: Went, + /// The finished sentence. Never a fragment a caller has to complete: a door + /// that had to add words would be a second place the wording lives. + pub said: String, +} + +impl Outcome { + /// The answer when io-cli has no home of its own to work in. See [`NOWHERE`]. + fn homeless() -> Self { + Outcome { + went: Went::Refused, + said: NOWHERE.to_string(), + } + } +} + +/// What a finished fetch means, as a sentence. +/// +/// Split out of [`add`] so that every ending has a test: `add` itself resolves the +/// operator's real home and is therefore three lines nothing under `tests/` can +/// drive without moving `HOME` out from under a suite running in parallel. +/// +/// `Cloned` is the one ending that counts what arrived, and it counts it because +/// the question an operator asks straight after adding a marketplace is what is in +/// it. Every other ending carries [`crate::fetch::Fetched::sentence`] verbatim — +/// the words of whoever refused, which for a failed clone is git's own last line. +#[must_use] +pub fn told(named: &Named, fetched: &Fetched) -> Outcome { + let name = { + let mut out = named.owner.clone(); + out.push('/'); + out.push_str(&named.repo); + out + }; + match fetched { + Fetched::Cloned(root) | Fetched::Already(root) => { + let market = Market { + named: named.clone(), + bundles: holdings(root), + root: root.clone(), + }; + let mut said = name; + said.push_str(if matches!(fetched, Fetched::Cloned(_)) { + " is here: " + } else { + " was already here: " + }); + said.push_str(&market.held()); + said.push_str(" in "); + said.push_str(&root.display().to_string()); + Outcome { + // **`Already` did not act, and saying otherwise is the lie this + // field exists to prevent.** `fetch::clone` answers an existing + // destination before it spawns anything and never clones over it, + // so an operator who expected an update got none — and it is + // `Already` rather than `Refused` because having it is what they + // asked for. + went: if matches!(fetched, Fetched::Cloned(_)) { + Went::Acted + } else { + Went::Already + }, + said, + } + } + // Carried whole. `sentence` answers `None` only for `Cloned`, which is + // handled above, so the fallback is unreachable and is written as the + // fact rather than as an `unwrap`. + other => Outcome { + went: Went::Refused, + said: other + .sentence() + .unwrap_or_else(|| String::from("git could not clone that")), + }, + } +} + +/// Delete a marketplace's clone, and nothing else. +/// +/// **Criterion F3 lives in what this function cannot reach.** It takes a directory +/// and a name; it holds no configuration, names no scope, and builds no +/// [`crate::edit::Edit`], so there is no path by which it could take a +/// `[[plugin]]` entry away with the clone even if a later edit tried. The sabotage +/// F3 names — removing the entries alongside the clone — would have to add an +/// argument to this signature to be written at all. +/// +/// A marketplace that is not there is reported rather than treated as a success: +/// "removed" over a name that was never here tells an operator their typo worked. +#[must_use] +pub fn discard(root: &Path, named: &Named) -> Outcome { + let clone = at(root, named); + if !clone.is_dir() { + let mut said = String::from("no marketplace called "); + said.push_str(&named.owner); + said.push('/'); + said.push_str(&named.repo); + said.push_str(" is here; `plugin marketplace list` shows the ones that are"); + return Outcome { + went: Went::Refused, + said, + }; + } + match std::fs::remove_dir_all(&clone) { + Ok(()) => { + let mut said = String::from("the clone is gone: "); + said.push_str(&clone.display().to_string()); + said.push_str( + ". No `[[plugin]]` entry was touched — a bundle declared out of this \ + marketplace is still declared", + ); + Outcome { + went: Went::Acted, + said, + } + } + Err(error) => { + let mut said = String::from("that marketplace could not be deleted: "); + said.push_str(&error.to_string()); + Outcome { + went: Went::Refused, + said, + } + } + } +} + +/// The declared bundles whose directory is inside `clone`. +/// +/// **What removing a marketplace actually costs, computed before it is removed.** +/// A bundle declared straight out of a marketplace — which is what `/plugin add` +/// over one of its directories writes — has a `[[plugin]] path` pointing inside +/// the clone. Deleting the clone leaves that entry exactly where it was, which is +/// F3's promise, and leaves io-harness dropping it on the next turn, which is +/// F3's promise read the other way round. Neither half is a defect; being told +/// only the first half is. +/// +/// Both of `view`'s lists are read, because a bundle inside the clone may already +/// be refused — a broken manifest does not stop it being the thing that is about +/// to disappear. +/// +/// **Compared on resolved paths, and that is not tidiness.** `~` on macOS is +/// reached through `/var` while the same directory canonicalises to `/private/var`, +/// and a `$HOME` that is a symlink is ordinary on a managed machine — so a raw +/// prefix comparison answers "nothing depends on this" for a bundle that is about +/// to stop loading, which is the one wrong answer this function can give. +#[must_use] +pub fn dependents(view: &crate::pluginview::View, clone: &Path) -> Vec { + let real = resolved(clone); + view.plugins + .iter() + .map(|listed| listed.root.clone()) + .chain(view.refused.iter().map(|refused| refused.path.clone())) + .filter(|root| resolved(root).starts_with(&real)) + .collect() +} + +/// A path with its symlinks followed, or the path itself where it cannot be. +/// +/// The fallback is the honest one: a path that does not exist cannot be resolved, +/// and refusing to compare it at all would drop a declared bundle whose directory +/// is already gone out of a list that is about to say what is going. +fn resolved(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +/// The line that says what a removal is about to cost, or `None` when it costs +/// nothing. +/// +/// One function so that the two doors cannot word this differently, and `None` +/// rather than an empty string so that neither door has to decide whether to draw +/// it — an empty warning drawn as a row is a row an operator reads as a warning. +#[must_use] +pub fn warning(inside: &[PathBuf]) -> Option { + if inside.is_empty() { + return None; + } + let mut said = if inside.len() == 1 { + String::from("1 declared bundle lives inside it") + } else { + format!("{} declared bundles live inside it", inside.len()) + }; + said.push_str( + ": the `[[plugin]]` entries are left exactly as they are, and io-harness will \ + report them as missing from the next turn — ", + ); + said.push_str( + &inside + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", "), + ); + Some(said) +} + +// --- the three that reach the operator's home --------------------------------- + +/// Fetch `named` into `~/.io-cli/marketplaces`. +/// +/// The whole of what both doors run for `plugin marketplace add`, so neither can +/// grow a fetch of its own — which is F1's named sabotage. +#[must_use] +pub fn add(named: &Named) -> Outcome { + match crate::fetch::fetch(named) { + None => Outcome::homeless(), + Some(fetched) => told(named, &fetched), + } +} + +/// Delete `named`'s clone from `~/.io-cli/marketplaces`. See [`discard`]. +#[must_use] +pub fn remove(named: &Named) -> Outcome { + match crate::home::marketplaces() { + None => Outcome::homeless(), + Some(root) => discard(&root, named), + } +} + +/// Every marketplace in `~/.io-cli/marketplaces`, or `None` with no home. +/// +/// `None` and an empty `Vec` are different answers and the surfaces say so +/// differently: nowhere to keep one, against nothing kept yet. +#[must_use] +pub fn installed() -> Option> { + Some(markets(&crate::home::marketplaces()?)) +} + +// --- rows --------------------------------------------------------------------- + +/// The picker rows for the marketplaces, one each, in [`markets`]' order. +/// +/// **The contract is positional and index `i` is `markets[i]`**, with no headings +/// and no interleaving — the rule `pluginview::rows` states for the same reason: +/// the picker hands a chosen index straight back into the list, and a row that +/// maps to no marketplace is the off-by-one that shipped a wrong delete in 0.20.0. +/// +/// The count is unconditional, because it is the answer to "what is in it" and is +/// the only field here with no other home; the path takes what is left and is +/// shortened from the left, since every clone on one machine shares the first +/// several segments of its path. +pub fn rows(markets: &[Market], width: u16, glyphs: &Glyphs) -> Vec { + let separator = glyphs.separator; + let separator_width = separator.chars().count(); + markets + .iter() + .map(|market| { + let name = market.name(); + let mut detail = market.held(); + // The picker's own arithmetic, mirrored rather than guessed: two cells + // of marker, the label, two cells of gap. + let left = (width as usize) + .saturating_sub(4) + .saturating_sub(name.chars().count()) + .saturating_sub(detail.chars().count()); + if left > separator_width + glyphs.ellipsis.chars().count() { + detail.push_str(separator); + detail.push_str(&fit_left( + &market.root.display().to_string(), + left - separator_width, + glyphs, + )); + } + Row::marked(HERE_MARK, name, detail) + }) + .collect() +} + +/// The picker rows for one marketplace's bundles, one each, in [`holdings`]' +/// order. +/// +/// **The label is the manifest's `name`** — see [`Bundle::label`] — and the detail +/// is [`Bundle::line`], so a bundle whose manifest names nothing says so on the row +/// rather than being drawn under a directory name as if that were its id. +/// +/// Index `i` is `market.bundles[i]`, with the same no-headings rule [`rows`] +/// states. +pub fn bundle_rows(market: &Market, width: u16, glyphs: &Glyphs) -> Vec { + market + .bundles + .iter() + .map(|bundle| { + let label = bundle.label(); + let room = (width as usize) + .saturating_sub(4) + .saturating_sub(label.chars().count()); + Row::with_detail(label, fit(&bundle.line(), room, glyphs)) + }) + .collect() +} diff --git a/tests/marketplace.rs b/tests/marketplace.rs new file mode 100644 index 0000000..47a773e --- /dev/null +++ b/tests/marketplace.rs @@ -0,0 +1,701 @@ +//! F1, F2 and F3 — a marketplace is added by name through one parse, holds the +//! directories that carry a manifest, and takes nothing declared with it when it +//! goes. +//! +//! Every test here drives `io_cli::marketplace` and `io_cli::manage` the way both +//! doors drive them: a token slice starting at the surface word, and a path passed +//! in rather than looked up. That is the whole reason the module takes its +//! directory as an argument — a decision behind `crate::home` is a decision nothing +//! under `tests/` can reach without moving `HOME` out from under a suite running in +//! parallel, and this crate has shipped untestable driver logic in three releases. +//! +//! **Nothing here spawns `git` and nothing here reaches a network.** `tests/fetch.rs` +//! owns the clone itself, both its endings and the staging directory; what is left +//! for this file is everything that happens to a directory once it is on the disk, +//! which is answerable against a fixture and is therefore answered against one. + +use std::path::{Path, PathBuf}; + +use io_cli::fetch::Named; +use io_cli::manage::{self, MarketVerb, PluginVerb, Request}; +use io_cli::marketplace::{self, Went}; + +/// The token slice a shell hands `io`, spelled the way a test can read. +fn argv(words: &[&str]) -> Vec { + words.iter().map(|word| word.to_string()).collect() +} + +/// The name used throughout, spelled once. +fn named() -> Named { + Named { + owner: "zeroonething".to_string(), + repo: "ultraship".to_string(), + } +} + +/// One of this crate's own source files, with its comments taken out. +/// +/// **The comments go, and 0.19.0 and 0.25.0 are why.** These gates assert that a +/// call does *not* appear in a file; a file that explains which call it is +/// deliberately not making would fail its own gate, and a rule that forbids a +/// module from documenting itself is a rule that gets deleted instead of obeyed. +/// The property is about what the code does, so the check reads code. +fn code_of(name: &str) -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(name); + // Normalised: a Windows checkout has `\r\n`, and a gate that sliced on `"\n"` + // matched nothing and panicked on a green product in 0.19.0 and 0.23.0. + std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("{name} is beside the tests: {error}")) + .replace("\r\n", "\n") + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n") +} + +/// Write `text` into `dir/plugin.toml`, creating the directory. +fn manifest(dir: &Path, text: &str) { + std::fs::create_dir_all(dir).expect("the bundle directory"); + std::fs::write(dir.join(io_cli::pluginview::MANIFEST), text).expect("the manifest"); +} + +// --- F1: one name, one parse, one destination --------------------------------- + +/// **F1 — the slash form and the argv form are one token slice and one request.** +/// +/// The comparison is against the **value** both must produce rather than +/// `parse(X)` against `parse(X)`: the token assertion above it has already +/// established that the two slices are the same slice, so comparing the two parses +/// would be a tautology — which is exactly the vacuity `tests/manage.rs` records +/// having shipped in its own F7 gate. +/// +/// The name is carried as a `Named` and not as text, so nothing downstream can +/// read it a second time with a second opinion about what a name may contain. +/// +/// Sabotage: give the argv form its own branch — see the structural test below, +/// which is where that arm dies, because a byte comparison cannot see two code +/// paths that were kept in step by hand until the release where one grew a flag. +#[test] +fn f1_the_slash_form_and_the_argv_form_are_one_parse() { + let typed = manage::tokens("/plugin marketplace add zeroonething/ultraship"); + assert_eq!( + typed, + argv(&["plugin", "marketplace", "add", "zeroonething/ultraship"]), + "the composer's tokeniser must leave what a shell leaves after the binary name", + ); + + let request = manage::parse(&typed).expect("the slash form parses"); + assert_eq!( + request, + Request::Plugin(PluginVerb::Marketplace(MarketVerb::Add(named()))), + "the slash form must parse to the request the argv form does, and to this one", + ); + + // And a `.git` suffix and a trailing slash — what a paste leaves behind — + // reach the same request rather than a second marketplace with a different + // directory. The judging is `fetch::resolve`'s, in one place. + assert_eq!( + manage::parse(&argv(&[ + "plugin", + "marketplace", + "add", + "zeroonething/ultraship.git/" + ])) + .expect("a pasted name parses"), + request, + ); + + // And the slash surface routes all three to that parse rather than opening the + // bundle panel over a line that asked for something else. Without this the + // composer form never reaches `manage` at all and the criterion is about a + // door nobody can open. + for line in [ + "plugin marketplace add zeroonething/ultraship", + "plugin marketplace list", + "plugin marketplace remove zeroonething/ultraship", + // A bare `marketplace` too: refused by name in the parse, which is a + // better answer than the list of bundles. + "plugin marketplace", + // The plural spelling io-cli already admits for this surface. + "plugins marketplace list", + ] { + assert!( + matches!( + io_cli::commands::parse(line, &io_cli::keys::Keys::default(), &io_cli::theme::DARK), + io_cli::commands::Action::Manage(_), + ), + "`/{line}` must reach `manage::parse`, or the slash form and `io …` can disagree", + ); + } +} + +/// **F1 — a marketplace verb writes no configuration at all.** +/// +/// `Ok(None)` is what `plan` answers for a read, and adding a marketplace is not a +/// read — it is a change to the disk that is not a change to any file io-harness +/// layers. The alternative shapes are both worse and both named in `plan`'s own +/// docs: an empty `Plan` makes `configure::write` create a file and report a write +/// that never happened, and a second member on `Plan` is the second write path the +/// module forbids. +/// +/// Sabotage: give the three verbs a `Plan`. Under it `io plugin marketplace list` +/// creates the operator's user configuration file as a side effect of a question. +#[test] +fn f1_a_marketplace_verb_plans_no_configuration_write() { + let root = tempfile::tempdir().expect("a temporary directory"); + for line in [ + "plugin marketplace add zeroonething/ultraship", + "plugin marketplace list", + "plugin marketplace remove zeroonething/ultraship", + ] { + let request = manage::parse(&manage::tokens(line)).expect(line); + assert!( + manage::plan(root.path(), &request) + .expect("a marketplace verb plans") + .is_none(), + "{line} planned a write to a configuration file", + ); + } + // And nothing was created on the way past — the failure `Ok(None)` exists to + // prevent leaves a file behind rather than an assertion. + assert!( + std::fs::read_dir(root.path()) + .expect("the root is readable") + .next() + .is_none(), + "a marketplace verb wrote something into the discovery root", + ); +} + +/// **F1 — the destination is one layout, spelled in one place.** +/// +/// `fetch::at` builds `/marketplaces//` and +/// `marketplace::at` builds `//`; if the two ever disagree an +/// `add` writes to one path and a `list` and a `remove` look in another, and the +/// marketplace an operator just fetched is invisible to the surface that fetched +/// it. +#[test] +fn f1_the_clone_sits_under_owner_and_repo() { + let root = PathBuf::from("/somewhere/marketplaces"); + assert_eq!( + marketplace::at(&root, &named()), + root.join("zeroonething").join("ultraship"), + ); + + // `fetch::at` answers `None` only where the operator has no home at all, which + // is not something a test may create; where there is one, the two functions + // must agree component for component. + if let (Some(home), Some(fetched)) = (io_cli::home::marketplaces(), io_cli::fetch::at(&named())) + { + assert_eq!(fetched, marketplace::at(&home, &named())); + assert!( + fetched.ends_with("marketplaces/zeroonething/ultraship"), + "the clone is two levels under the marketplaces directory: {}", + fetched.display(), + ); + } +} + +/// **F1's named sabotage — neither door may read a marketplace name of its own.** +/// +/// A byte comparison of two results cannot see this: two branches kept in step by +/// hand pass it until the release where one of them gains a flag. So the property +/// is asserted over the sources as text, which is the instrument `tests/manage.rs` +/// and `tests/servers.rs` already use on `src/main.rs` — nothing under `tests/` +/// links the driver. +/// +/// Sabotage: resolve the name in `manage_main` and clone it there. Under it +/// `fetch::resolve` or `fetch::fetch` appears in `src/main.rs` and this fails +/// naming the door that grew the branch. +#[test] +fn f1_the_driver_resolves_no_name_and_clones_nothing_itself() { + let manage = code_of("src/manage.rs"); + let driver = code_of("src/main.rs"); + let module = code_of("src/marketplace.rs"); + + assert_eq!( + manage.matches("fetch::resolve(").count(), + 1, + "a marketplace name is judged in exactly one place, and it is the parse", + ); + assert_eq!( + driver.matches("fetch::resolve(").count(), + 0, + "src/main.rs judges a marketplace name itself, so the argv door has grown a \ + reading of its own and the two doors can disagree about what a name may contain", + ); + assert_eq!( + driver.matches("fetch::fetch(").count(), + 0, + "src/main.rs clones a marketplace itself rather than through \ + `marketplace::add`, which is the branch F1 forbids", + ); + assert_eq!( + module.matches("fetch::fetch(").count(), + 1, + "the fetch has one call site, so neither door can reach a different one", + ); + + // **The two verbs have different door counts, and the asymmetry is real rather + // than an oversight.** The first draft of this gate asserted two for each, on + // the assumption that the panel and the argument form were the two doors for + // both. They are not: + // + // - `add` has **two**. The panel's "add a marketplace" row prefills the + // composer with `/plugin marketplace add ` rather than acting, because a + // name is free text and there is nothing to choose from — so that row + // arrives back through the typed door and adds no call site of its own. + // - `remove` has **three**. The panel *can* offer a specific marketplace, + // because by then it is a row the operator picked, so it acts directly after + // the `store::LEAVE_IT` confirmation. That is a real third door. + // + // What the numbers guard is unchanged: every one of them goes through the + // library rather than doing the work in the driver. A count one higher than + // the door list below is a surface acting without the parse; a count of zero + // is a door that cannot do it at all. + for (verb, doors) in [("marketplace::add(", 2), ("marketplace::remove(", 3)] { + assert_eq!( + driver.matches(verb).count(), + doors, + "`{verb}` is called from {} places in src/main.rs, not {doors}. The doors \ + are: the typed slash form, the argument form, and — for `remove` only — \ + the panel's confirmed removal of a marketplace the operator picked. \ + Every one goes through the library.", + driver.matches(verb).count(), + ); + } +} + +/// Every refusal names what was wrong and what is accepted instead. +/// +/// The sub-verb list and the surface's own verb list are two sentences that both +/// have to know that `marketplace` exists — `src/manage.rs`'s own note records +/// that a stale refusal tells an operator a verb does not exist. +#[test] +fn a_marketplace_refusal_names_what_is_accepted() { + for (line, expected) in [ + // The surface's verb list must carry the new verb. + ("plugin wat", "marketplace"), + ("plugin", "marketplace"), + // The sub-verb's own list. + ("plugin marketplace", "add "), + ("plugin marketplace wat", "add "), + // The name, judged by `fetch::resolve` and refused by name. + ( + "plugin marketplace add https://github.com/a/b", + "/", + ), + ("plugin marketplace add ../../etc", "/"), + ("plugin marketplace add a/b/c", "/"), + // A leading dash never even reaches `resolve`: the scan refuses it as a + // flag io does not have, which is the earlier of the two guards against a + // name that would become an option. + ("plugin marketplace add -rf", "two dashes"), + // A marketplace has no scope, because it is written into no file. + ("plugin marketplace add a/b --scope user", "no flags at all"), + // And the list verb takes nothing. + ("plugin marketplace list extra", "takes no arguments"), + ] { + let refusal = manage::parse(&manage::tokens(line)).expect_err(line); + assert!( + refusal.contains(expected), + "`{line}` was refused without naming `{expected}`: {refusal}", + ); + } +} + +// --- F2: what a marketplace holds, by the manifest's own names ---------------- + +/// The fixture both F2 tests read: two marketplaces, one of them its own bundle. +fn fixture(root: &Path) { + let clone = root.join("zeroonething").join("ultraship"); + // A bundle whose directory and whose `name` are deliberately different. This + // is F2's sabotage arm: returning the directory's own filename answers + // `rust` here, and io-harness would namespace nothing by that word. + manifest( + &clone.join("plugins").join("rust"), + "name = \"rust-review\"\ndescription = \"Everything our Rust reviews need.\"\n\ + version = \"1.2.0\"\n", + ); + // A manifest that names nothing. Still a bundle, listed under its directory, + // with the row saying the manifest did not name it. + manifest(&clone.join("plugins").join("bare"), "version = \"0.1.0\"\n"); + // Not a bundle: no manifest. + std::fs::create_dir_all(clone.join("plugins").join("docs")).expect("a plain directory"); + std::fs::write(clone.join("plugins").join("docs").join("README.md"), "hi").expect("a file"); + // A dotted directory carrying a manifest. Every clone has a `.git`, and a + // walk that counted what is inside one would report a marketplace's own + // metadata as a capability bundle. + manifest( + &clone.join(".git").join("hooks"), + "name = \"not-a-bundle\"\n", + ); + + // A marketplace that IS one bundle — a plugin published as its own + // repository. `pluginview::candidates` only ever looks at a directory's + // children, so this is the shape a walk without the root check misses whole. + manifest( + &root.join("otherowner").join("single"), + "name = \"single\"\n", + ); +} + +/// **F2 — a marketplace's contents are the directories that carry a manifest, and +/// their own names.** +/// +/// Sabotage: return the directory's own filename instead of the manifest's `name` +/// — the `rust` / `rust-review` assertion below fails, and it fails on the fact +/// that matters, because io-harness namespaces every contribution a bundle makes +/// by the manifest's name and never by the directory's. +#[test] +fn f2_a_marketplace_holds_the_directories_that_carry_a_manifest() { + let dir = tempfile::tempdir().expect("a temporary directory"); + fixture(dir.path()); + + let markets = marketplace::markets(dir.path()); + assert_eq!( + markets.len(), + 2, + "exactly two levels are a marketplace: {:?}", + markets + .iter() + .map(marketplace::Market::name) + .collect::>(), + ); + // Ordered by owner and then by repository, so the row an operator picked + // yesterday is the row in the same place today. + assert_eq!(markets[0].name(), "otherowner/single"); + assert_eq!(markets[1].name(), "zeroonething/ultraship"); + + // The marketplace that is itself one bundle. Its own root is the bundle. + assert_eq!(markets[0].bundles.len(), 1); + assert_eq!(markets[0].bundles[0].dir, markets[0].root); + assert_eq!(markets[0].bundles[0].label(), "single"); + assert_eq!(markets[0].held(), "1 bundle"); + + let held = &markets[1]; + assert_eq!( + held.bundles.len(), + 2, + "a directory with no manifest is not a bundle, and a dotted directory is \ + never walked: {:?}", + held.bundles + .iter() + .map(|b| b.dir.clone()) + .collect::>(), + ); + assert_eq!(held.held(), "2 bundles"); + assert!( + held.bundles.iter().all(|bundle| !bundle + .dir + .components() + .any(|part| part.as_os_str().to_string_lossy() == ".git")), + "a clone's own `.git` was counted as holding a bundle", + ); + + let review = held + .bundles + .iter() + .find(|bundle| bundle.dir.ends_with("rust")) + .expect("the bundle in `plugins/rust`"); + assert_eq!( + review.name.as_deref(), + Some("rust-review"), + "the manifest's `name`, unquoted — not the directory's own filename", + ); + assert_eq!(review.label(), "rust-review"); + assert_eq!( + review.description.as_deref(), + Some("Everything our Rust reviews need."), + "the manifest's `description`, unquoted", + ); + assert_eq!(review.line(), "Everything our Rust reviews need."); + + let bare = held + .bundles + .iter() + .find(|bundle| bundle.dir.ends_with("bare")) + .expect("a manifest that names nothing is still a bundle"); + assert_eq!(bare.name, None); + assert_eq!( + bare.label(), + "bare", + "a nameless manifest is listed under its directory rather than dropped", + ); + assert!( + bare.line().contains("does not name it"), + "the row must say the label is io-cli's guess: {}", + bare.line(), + ); +} + +/// **F2 — the rows draw the manifest's name and the count, in both glyph sets.** +/// +/// The label is what an operator reads and what they will type; the count is the +/// answer to "what is in it" and is the one field on a marketplace row with no +/// other home, so it is unconditional and the path is what gives way. +#[test] +fn f2_the_rows_carry_the_manifest_name_and_the_count() { + let dir = tempfile::tempdir().expect("a temporary directory"); + fixture(dir.path()); + let markets = marketplace::markets(dir.path()); + + for glyphs in [&io_cli::glyphs::UNICODE, &io_cli::glyphs::ASCII] { + let rows = marketplace::rows(&markets, 200, glyphs); + assert_eq!(rows.len(), markets.len(), "{}", glyphs.name); + assert_eq!(rows[1].label, "zeroonething/ultraship", "{}", glyphs.name); + let detail = rows[1] + .detail + .clone() + .expect("a marketplace row has detail"); + assert!(detail.starts_with("2 bundles"), "{}: {detail}", glyphs.name); + + // A narrow terminal drops the path and keeps the count, which is the + // field with no other home. The picker's own budget: marker, label, gap. + let narrow = marketplace::rows(&markets, 40, glyphs); + let detail = narrow[1] + .detail + .clone() + .expect("a marketplace row has detail"); + assert!(detail.starts_with("2 bundles"), "{}: {detail}", glyphs.name); + assert!( + narrow[1].label.chars().count() + detail.chars().count() + 4 <= 40, + "{}: the row fits: {detail}", + glyphs.name, + ); + + let bundles = marketplace::bundle_rows(&markets[1], 200, glyphs); + let labels: Vec<&str> = bundles.iter().map(|row| row.label.as_str()).collect(); + assert!( + labels.contains(&"rust-review"), + "{}: the manifest's name is the label: {labels:?}", + glyphs.name, + ); + assert!( + !labels.contains(&"rust"), + "{}: the directory's own filename was drawn as the bundle's name: {labels:?}", + glyphs.name, + ); + } +} + +// --- F3: removing a marketplace touches nothing that is declared --------------- + +/// **F3 — a marketplace is removed, and removing it does not touch anything +/// installed from it.** +/// +/// Two bundles are declared: one copied out of the marketplace into the workspace, +/// which is what installing one means, and one left inside the clone, which is what +/// declaring one in place means. After the removal the configuration file is +/// **byte-identical**, both entries are still declared, and the copied bundle is +/// still loaded. +/// +/// Sabotage: remove the `[[plugin]]` entries alongside the clone. The byte +/// comparison fails first and names it; `discard` could not express that edit +/// anyway, because it holds no configuration and builds no `Edit`. +#[test] +fn f3_removing_a_marketplace_leaves_every_declaration_alone() { + let work = tempfile::tempdir().expect("a workspace"); + let store = tempfile::tempdir().expect("a marketplaces directory"); + // Resolved, because a temporary directory on macOS is reached through `/var` + // and canonicalises to `/private/var`: a fixture that declared the unresolved + // path would be comparing two spellings of one directory. + let root = std::fs::canonicalize(work.path()).expect("the workspace resolves"); + let store = std::fs::canonicalize(store.path()).expect("the store resolves"); + + // Both bundles contribute something real, so that "it loaded" is a claim about + // io-harness having accepted a bundle rather than about a manifest carrying a + // name. A skills directory is the cheapest contribution a local-scope bundle + // may make; it exists, because a bundle naming a directory that does not is a + // different failure and would answer this test for the wrong reason. + let clone = marketplace::at(&store, &named()); + let inside = clone.join("plugins").join("rust"); + manifest(&inside, "name = \"rust-review\"\nskills = \"skills\"\n"); + std::fs::create_dir_all(inside.join("skills")).expect("the skills directory"); + // Installed: the same bundle, copied out of the marketplace and into the + // operator's own workspace. This is the one F3 says stays loaded. + let installed = root.join("bundles").join("rust-review"); + manifest( + &installed, + "name = \"rust-review-copy\"\nskills = \"skills\"\n", + ); + std::fs::create_dir_all(installed.join("skills")).expect("the skills directory"); + + let file = root.join("io.local.toml"); + // `{:?}` on the rendered path, which is a TOML basic string with its + // backslashes escaped — the same reason `pluginview::quoted` exists, and the + // reason a Windows checkout does not write a file that no longer parses. + let text = format!( + "[[plugin]]\npath = {:?}\n\n[[plugin]]\npath = {:?}\n", + installed.display().to_string(), + inside.display().to_string(), + ); + std::fs::write(&file, &text).expect("the configuration"); + + let config = io_harness::Config::discover(&root).expect("the configuration loads"); + let view = io_cli::pluginview::view(&config); + // By id and never by count: `Config::discover` layers the operator's own user + // file over this one, so a developer whose `~/.io-cli` declares a bundle would + // fail a length assertion for a reason that has nothing to do with the + // criterion. `tests/plugins.rs` and `pluginview`'s own test read this the same + // way and for the same reason. + for id in ["rust-review", "rust-review-copy"] { + assert!( + view.plugins.iter().any(|listed| listed.id == id), + "{id} did not load before the removal: {view:?}", + ); + } + + // What the removal is about to cost, named before it happens. + let depends = marketplace::dependents(&view, &clone); + assert_eq!( + depends.len(), + 1, + "exactly the bundle declared inside the clone depends on it: {depends:?}", + ); + let warned = marketplace::warning(&depends).expect("a dependent bundle is warned about"); + assert!( + warned.contains("left exactly as they are"), + "the warning must say the entries survive: {warned}", + ); + assert!( + marketplace::warning(&[]).is_none(), + "an empty warning drawn as a row is a row an operator reads as a warning", + ); + + let outcome = marketplace::discard(&store, &named()); + assert_eq!(outcome.went, Went::Acted, "{}", outcome.said); + assert!(!clone.exists(), "the clone is what goes"); + + // **The assertion F3's sabotage dies on.** Not "the entries are still there" + // read back through a parser that might normalise them — the same bytes. + assert_eq!( + std::fs::read_to_string(&file).expect("the configuration is still there"), + text, + "removing a marketplace rewrote the configuration file", + ); + + let after = io_cli::pluginview::view( + &io_harness::Config::discover(&root).expect("the configuration still loads"), + ); + assert!( + after + .plugins + .iter() + .any(|listed| listed.id == "rust-review-copy"), + "a bundle installed out of a marketplace stops loading when the marketplace \ + goes: {after:?}", + ); + // And the honest other half: an entry declared *inside* the clone is still + // declared — the bytes above say so — and io-harness now reports it as + // missing rather than silently forgetting it. It is a refusal, not a + // deletion, which is the whole distinction F3 draws. + assert!( + after + .refused + .iter() + .any(|refused| refused.path.starts_with(&clone)), + "the entry declared inside the clone was dropped from the configuration \ + rather than reported: {after:?}", + ); +} + +/// Removing something that is not here says so rather than reporting success. +/// +/// "Removed" over a name that was never there tells an operator their typo +/// worked, and the next thing they do is look for a marketplace they think is +/// gone. +#[test] +fn f3_removing_a_marketplace_that_is_not_here_is_not_a_success() { + let store = tempfile::tempdir().expect("a marketplaces directory"); + let outcome = marketplace::discard(store.path(), &named()); + assert_eq!(outcome.went, Went::Refused); + assert!( + outcome.said.contains("zeroonething/ultraship") && outcome.said.contains("list"), + "the refusal names what was asked for and how to see what is here: {}", + outcome.said, + ); +} + +/// **`discard` cannot reach a configuration file, and that is where F3 lives.** +/// +/// A gate over the module's own text, because the criterion is about something the +/// code must not be able to do rather than about an outcome: an `Edit`, a `Scope` +/// or a `configure::write` in this module would be the sabotage arm compiling. +#[test] +fn f3_the_module_builds_no_edit_and_names_no_scope() { + let module = code_of("src/marketplace.rs"); + for forbidden in [ + "edit::Edit", + "Edit::set", + "Edit::remove", + "configure::write", + "Scope::", + ] { + assert!( + !module.contains(forbidden), + "src/marketplace.rs names `{forbidden}`. Removing a marketplace deletes a \ + clone and nothing else; a module that can build an edit is a module that \ + can take a `[[plugin]]` entry away with it.", + ); + } +} + +/// A fetch that ended without cloning is reported as what it was. +/// +/// Three endings, three sentences, and the middle one is the trap: a marketplace +/// that was already here is what the operator asked for, so it must not be drawn +/// as a refusal and must not exit non-zero — while a machine with no git and a +/// clone git rejected both must. +#[test] +fn a_fetch_that_did_not_clone_says_which_of_the_three_it_was() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let clone = marketplace::at(dir.path(), &named()); + manifest( + &clone.join("plugins").join("rust"), + "name = \"rust-review\"\n", + ); + + let already = marketplace::told(&named(), &io_cli::fetch::Fetched::Already(clone.clone())); + assert_eq!( + already.went, + Went::Already, + "a marketplace that was already here is not a refusal: {}", + already.said, + ); + assert!( + already.said.contains("1 bundle"), + "the count is what an operator asks for next: {}", + already.said, + ); + + let cloned = marketplace::told(&named(), &io_cli::fetch::Fetched::Cloned(clone)); + assert_eq!(cloned.went, Went::Acted); + assert!( + cloned.said.contains("zeroonething/ultraship"), + "{}", + cloned.said + ); + + let missing = marketplace::told(&named(), &io_cli::fetch::Fetched::NoGit); + assert_eq!(missing.went, Went::Refused); + assert!( + missing.said.contains("git"), + "the sentence is `Fetched`'s own, about the machine: {}", + missing.said, + ); + + let failed = marketplace::told( + &named(), + &io_cli::fetch::Fetched::Failed { + status: Some(128), + stderr: "Cloning into 'x'...\nfatal: repository not found\n".to_string(), + }, + ); + assert_eq!(failed.went, Went::Refused); + assert!( + failed.said.contains("repository not found"), + "git's own last line, carried: {}", + failed.said, + ); +} From fc8bf14d9608926b0eda8e8a6bf2eaf83cabed81 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 18:28:56 +0530 Subject: [PATCH 07/12] docs: take in the updated screenshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captured at v0.28.0, with the card's model and the footer's model agreeing. The alt text gained the two things the picture now shows and did not before — the tagline beside the version, and the muted line saying this is a new conversation with the last one still in /resume. Its account of the footer is unchanged and was already right: state, model and clock on the first row, the keys and the posture on the second. Alt text is the only form of this picture a screen reader gets, so it is checked against the image rather than carried forward. --- README.md | 8 +++++--- docs/screenshot.png | Bin 129121 -> 126330 bytes 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a8150d0..7d95451 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,11 @@ if this crate ever grows an HTTP client, a TLS stack, a database or a sandbox. - [What this release is not](#what-this-release-is-not) · [Platform support](#platform-support) · [Stability](#stability) ![A session at rest: the IO CLI card in the terminal's own scrollback, carrying -the version, the model, the permission posture and the workspace; a prompt below -it; and a two-row footer under a rule, naming the state, the model and the clock -on one row and the keys and the posture on the next.](docs/screenshot.png) +the version and the tagline above the model, the permission posture and the +workspace; a muted line saying this is a new conversation and the last one is +still in /resume; an empty prompt below it; and a two-row footer under a rule, +naming the state, the model and the clock on one row and the keys and the +posture on the next.](docs/screenshot.png) ## What you get diff --git a/docs/screenshot.png b/docs/screenshot.png index 5f60eb5ee36f516da18100f1c1dc549c603f9c7f..d78b135a409ae7994e2aa092964f99aed60ac41e 100644 GIT binary patch delta 72839 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³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 KD8B#V&Hn>Ydho*l From 2a33922d636d86022abd218a8fbba8fb1422d54c Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 18:36:05 +0530 Subject: [PATCH 08/12] feat(marketplace): install a bundle by name, and search across marketplaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/plugin add `, `@`, and `install` as the same verb under another word. `/plugin search ` reads names and descriptions across every added marketplace and prints one finished line per hit, whose first field is exactly what `plugin add` takes. **A word is a path if it resolves to a directory carrying a `plugin.toml`, and a name otherwise.** The alternative is reading the shape of the word — a slash, a leading dot, an extension — and that makes one word mean different things depending on the operator's working directory: `plugin add rust-review` would be a directory on a machine that has one and a marketplace bundle on a machine that does not, with no way to tell which happened. Asking the disk cannot drift, it can never make a real relative path unusable, and the check is `pluginview::refusal`, which this verb already had to pass before writing anything, so it is not a second reader. When both readings fail the two refusals are joined rather than chosen between, because io-cli does not know which was meant and reporting one half hides the other. **A bare name two marketplaces carry is refused, naming every qualified spelling.** Resolving to the first match installs code the operator did not choose, and `shared@ultraship` resolving to the second marketplace is asserted for the same reason — a first-match resolver gets that wrong too. The install ends in the existing `pluginview::add` → `configure::write`. No second writer; `manage::plan` still returns `Ok(None)` for every read. `verbs("plugin")` was updated in the same edit as the parse, and four rows were added to the refusal table that holds the two in step. `src/cli.rs`'s `io --help` text described three verbs and now describes what the subcommand actually does — flagged rather than left, because that string is the first thing a shell user reads. --- src/cli.rs | 3 +- src/commands.rs | 5 +- src/main.rs | 40 +++++++ src/manage.rs | 64 +++++++++-- src/marketplace.rs | 174 ++++++++++++++++++++++++++++ tests/marketplace.rs | 262 ++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 535 insertions(+), 13 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index ba46b0e..530f7da 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -82,7 +82,8 @@ pub enum Command { /// Add, list, inspect, change or remove an MCP server, without opening a /// session. Mcp(Manage), - /// Add, list or remove a capability bundle, without opening a session. + /// Add, list, search for or remove a capability bundle, and manage the + /// marketplaces bundles come from, without opening a session. Plugin(Manage), /// Read or write one configuration key, without opening a session. Config(Manage), diff --git a/src/commands.rs b/src/commands.rs index 27cefcd..ed30e97 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1977,10 +1977,13 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { // nothing else. A bare `/plugin marketplace` reaches the parse too and is // refused there by name — which is better than opening the bundle list // over a line that asked for something else. + // `install` is `add`'s other spelling and `search` is a read across the + // marketplaces; both are `manage::parse`'s words, so both route here rather + // than opening the panel over a line that asked for something else. "plugin" | "plugins" if matches!( input.split_whitespace().nth(1), - Some("add" | "remove" | "marketplace") + Some("add" | "install" | "remove" | "search" | "marketplace") ) => { Action::Manage(input.to_string()) diff --git a/src/main.rs b/src/main.rs index 6dcb995..41e44c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3729,6 +3729,32 @@ async fn loop_over Result>( ), } } + // **Every marketplace, one line each, through the same + // function the argument door prints.** `record` and never + // `say`: a search answers with as many lines as it found + // and the footer is one slot the next keystroke takes back. + Ok(( + io_cli::manage::Request::Plugin(io_cli::manage::PluginVerb::Search { + text, + }), + None, + )) => match io_cli::marketplace::installed() { + None => { + app.record(Tone::Refused, io_cli::marketplace::NOWHERE.to_string()); + } + Some(markets) => { + let hits = io_cli::marketplace::matching(&markets, &text); + if hits.is_empty() { + app.record( + Tone::Muted, + format!("no bundle in any marketplace matches `{text}`"), + ); + } + for hit in hits { + app.record(Tone::Muted, hit); + } + } + }, // **The three verbs that change the disk and no file.** // `plan` answers `None` for all of them — there is no // scope, no `[[…]]` entry and no value to spell — so they @@ -7704,6 +7730,20 @@ fn manage_main( eprintln!("{}: {}", refused.path.display(), refused.error); } } + // **One line per hit, and every marketplace is read.** The line is + // `marketplace::matching`'s, so the two doors cannot describe a hit + // differently, and its first field is the qualified spelling `plugin add` + // takes — a script piping this has the thing to install, not a name it has + // to go and disambiguate. Nothing at all is printed for no hits: a listing + // verb that wrote prose to stdout would put a sentence in the middle of + // somebody's pipeline. + io_cli::manage::Request::Plugin(io_cli::manage::PluginVerb::Search { text }) => { + let markets = io_cli::marketplace::installed() + .ok_or_else(|| io_cli::marketplace::NOWHERE.to_string())?; + for hit in io_cli::marketplace::matching(&markets, text) { + println!("{hit}"); + } + } // **Answered here and returned from, because none of the three plans an // edit.** A marketplace is a clone on the disk rather than a line in a // configuration file, so `manage::plan` says `None` for all three and the diff --git a/src/manage.rs b/src/manage.rs index bf53777..79bd716 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -3,7 +3,8 @@ //! `/mcp add …` typed into a composer and `io mcp add …` typed at a shell are the //! same sentence arriving through two doors, and this module is the only room //! behind both of them. Every verb these releases add — `mcp add|list|get|edit| -//! remove`, `plugin add|list|remove`, `plugin marketplace add|list|remove`, +//! remove`, `plugin add|install|list|search|remove`, `plugin marketplace +//! add|list|remove`, //! `config get|set|unset|list` — is turned into a [`Request`] here and into //! [`crate::edit::Edit`]s by [`plan`], and neither entry point is allowed a second //! reading of the same words. @@ -118,10 +119,20 @@ pub enum McpVerb { /// What `/plugin` and `io plugin` can be asked to do. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PluginVerb { - /// Declare a bundle directory. + /// Declare a bundle. `path` is the word as typed — **a directory or the name + /// of a bundle a marketplace holds** — and which of the two it was is decided + /// against the disk by [`crate::marketplace::chosen`] in [`plan`], never here: + /// a parse that judged it would be judging without having looked. + /// + /// Still a `PathBuf` for the one word, because the reading that wins in every + /// existing case is the path and a `String` here would make the common verb + /// convert at both call sites to serve the rarer one. Add { path: PathBuf, scope: Scope }, /// Every declared bundle, loaded and refused. List, + /// Every bundle in every added marketplace whose name or description carries + /// `text`. A read: it opens no file and writes none. + Search { text: String }, /// Undeclare a bundle. No scope: the file that named it is the file the /// removal has to go to, and [`plan`] finds it. Remove { path: PathBuf }, @@ -284,10 +295,18 @@ pub fn parse(tokens: &[String]) -> Result { id: args.one_word("mcp remove", "the id of a configured server")?, })) } - ("plugin", Some("add")) => { + // **`install` is the same verb and not a second one.** It is the word an + // operator arriving from any other tool types for this, and the whole cost + // of admitting it is this pattern — one arm, one request, one refusal. + // Spelling it as a second variant would be two verbs to keep writing the + // same entry. The refusal below and `verbs` are updated together, always. + ("plugin", Some("add" | "install")) => { args.only("plugin add", &["scope"])?; Ok(Request::Plugin(PluginVerb::Add { - path: PathBuf::from(args.one_word("plugin add", "the directory of a bundle")?), + path: PathBuf::from(args.one_word( + "plugin add", + "the directory of a bundle, or the name of one a marketplace holds", + )?), scope: args.scope()?, })) } @@ -295,6 +314,15 @@ pub fn parse(tokens: &[String]) -> Result { args.nothing("plugin list")?; Ok(Request::Plugin(PluginVerb::List)) } + ("plugin", Some("search")) => { + args.only("plugin search", &[])?; + Ok(Request::Plugin(PluginVerb::Search { + text: args.one_word( + "plugin search", + "some text to look for in every marketplace's bundles", + )?, + })) + } // **Scanned from the sub-verb rather than from `args` above**, because // `args` was sorted out of `tokens[2..]` and here `tokens[2]` is the verb // itself: reading the name out of `args.positional[1]` would make @@ -351,7 +379,7 @@ pub fn parse(tokens: &[String]) -> Result { fn verbs(surface: &str) -> &'static str { match surface { "mcp" => "`add`, `list`, `get`, `edit` and `remove`", - "plugin" => "`add`, `list`, `remove` and `marketplace`", + "plugin" => "`add` (also spelled `install`), `list`, `search`, `remove` and `marketplace`", _ => "`get`, `set`, `unset` and `list`", } } @@ -494,10 +522,21 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { } } Request::Plugin(PluginVerb::Add { path, scope }) => { - let dir = resolve(root, path); - if let Some(refusal) = crate::pluginview::refusal(&dir) { - return Err(refusal); - } + // **Both readings of the word, decided in one place.** A directory + // carrying a manifest is a path and everything else is a name looked + // up across the marketplaces — the rule, and the refusal when neither + // reading holds, are `marketplace::chosen`'s so that this arm cannot + // grow a second opinion about which was meant. The marketplaces are + // read behind a closure, so an ordinary `plugin add ./bundles/x` still + // walks nothing but the directory it was given. + // + // `display()` gives back the word as typed: `path` is a `PathBuf` made + // out of one token and is never rendered from components. + let dir = crate::marketplace::chosen( + &resolve(root, path), + || crate::marketplace::installed().unwrap_or_default(), + &path.display().to_string(), + )?; Plan { scope: *scope, edits: vec![crate::pluginview::add(&crate::pluginview::declared( @@ -547,8 +586,13 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { // true of it here: **nothing is written to any configuration file**. That // is also exactly criterion F3 — removing a marketplace cannot touch a // `[[plugin]]` entry, because this function never builds one. + // + // `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 { .. }) - | Request::Plugin(PluginVerb::List | PluginVerb::Marketplace(_)) + | Request::Plugin( + PluginVerb::List | PluginVerb::Search { .. } | PluginVerb::Marketplace(_), + ) | Request::Config(ConfigVerb::Get { .. } | ConfigVerb::List) => return Ok(None), }; Ok(Some(plan)) diff --git a/src/marketplace.rs b/src/marketplace.rs index d046d1d..b00106a 100644 --- a/src/marketplace.rs +++ b/src/marketplace.rs @@ -44,6 +44,26 @@ //! absence `pluginview`'s module docs exist to end. It is listed under its //! directory's own name, with [`Bundle::line`] saying the manifest did not name it. //! +//! # A bundle is asked for by name, and the disk decides which reading that was +//! +//! `plugin add ` took a path and from 0.29.0 it takes a name as well, which +//! is one verb with two readings of one word. [`chosen`] is the whole rule and it +//! asks the disk rather than the spelling: a word that resolves to a directory +//! carrying a manifest is a path, and every other word is a name looked up with +//! [`locate`]. A rule keyed on a `/` or a leading `.` would make one word mean +//! different things in different working directories; this one cannot, and a real +//! relative path can never become unreachable under it. +//! +//! **A bare name two marketplaces carry is refused.** They are two repositories' +//! code, and resolving to whichever the walk reached first installs something the +//! operator did not choose — so the refusal spells `@/` for each +//! of them instead. [`matching`] is the same lists read the other way round, and it +//! reads *every* marketplace: a second one is added precisely because the first did +//! not hold what was wanted. +//! +//! Nothing here writes: [`chosen`] answers with a directory and the entry is +//! written by [`crate::pluginview::add`], the edit `/plugin add` already had. +//! //! # Removing a marketplace removes a clone and nothing else //! //! [`discard`] deletes a directory. It does not touch a `[[plugin]]` entry, it @@ -532,6 +552,160 @@ pub fn warning(inside: &[PathBuf]) -> Option { Some(said) } +// --- asking for a bundle by name ---------------------------------------------- + +/// A query split into `` and the `@` that qualifies it. +/// +/// `rsplit_once`, so the **last** `@` is the separator: a bundle whose own name +/// carries one is still reachable, and the qualifier is the part nothing else can +/// contain. A query with nothing on one side of the `@` — `@thing`, `thing@` — is +/// read as a whole name rather than as half a qualification, because the half that +/// is missing is the half that would decide, and guessing it is how a name resolves +/// to a marketplace nobody asked for. +fn asked(query: &str) -> (&str, Option<&str>) { + match query.rsplit_once('@') { + Some((name, market)) if !name.is_empty() && !market.is_empty() => (name, Some(market)), + _ => (query, None), + } +} + +/// The directory of the bundle called `query`, across every marketplace here. +/// +/// The name is [`Bundle::label`]'s — the manifest's `name`, and a nameless +/// manifest's directory — because that is the word the listing drew and therefore +/// the word an operator has to type. A qualifier is matched against **both** +/// spellings of a marketplace, `/` and the repository alone, since the +/// second is what a hand reaches for and the first is what is always unique. +/// +/// **Two marketplaces carrying one name is a refusal and never a first match**, and +/// that is F4's named sabotage. The two bundles are two different repositories' +/// code: resolving to whichever the walk happened to reach first installs something +/// the operator did not choose, silently, under a name they believed meant one +/// thing. The refusal spells both qualified forms, so the fix is a paste rather +/// than a lookup. +pub fn locate(markets: &[Market], query: &str) -> Result { + let (name, qualifier) = asked(query); + let hits: Vec<(&Market, &Bundle)> = markets + .iter() + .flat_map(|market| market.bundles.iter().map(move |bundle| (market, bundle))) + .filter(|(market, bundle)| { + bundle.label() == name + && qualifier + .is_none_or(|which| which == market.name() || which == market.named.repo) + }) + .collect(); + match hits.as_slice() { + [(_, bundle)] => Ok(bundle.dir.clone()), + [] => Err(unheld(markets, query)), + several => { + let spellings = several + .iter() + .map(|(market, _)| format!("`{name}@{}`", market.name())) + .collect::>() + .join(" and "); + Err(format!( + "{} marketplaces here hold a bundle called `{name}`, and installing whichever \ + was found first would install code you did not choose; say which one: {spellings}", + several.len() + )) + } + } +} + +/// What to say when no marketplace holds the name that was asked for. +/// +/// The list of what *is* here is the whole point of the sentence: a bare "not +/// found" over a set of clones an operator fetched weeks ago leaves them running +/// `plugin marketplace list` and then descending into each one. Every bundle is +/// named in the qualified form, which is the form that always resolves. +fn unheld(markets: &[Market], query: &str) -> String { + let held: Vec = markets + .iter() + .flat_map(|market| { + market + .bundles + .iter() + .map(move |bundle| format!("`{}@{}`", bundle.label(), market.name())) + }) + .collect(); + if held.is_empty() { + return format!( + "no marketplace here holds a bundle called `{query}`, and no marketplace here holds \ + any bundle at all; `plugin marketplace add /` fetches one" + ); + } + format!( + "no marketplace here holds a bundle called `{query}`; the bundles that are here are {}", + held.join(", ") + ) +} + +/// Which directory `plugin add ` means: the path, or the bundle of that name. +/// +/// **The rule is one question asked of the disk, and it is asked of the path +/// first.** A word is a path when it resolves to a directory carrying a +/// [`MANIFEST`] — which is [`crate::pluginview::refusal`]'s own check, the one this +/// verb already had to pass before it wrote anything — and it is a name in every +/// other case. Nothing about the *shape* of the word is read: a rule keyed on a +/// `/`, a leading `.` or an extension would make `plugin add rust-review` mean a +/// directory on a machine that has one and a marketplace bundle on a machine that +/// does not, which is the same word doing two things depending on the operator's +/// working directory. Asking the disk cannot drift, and it cannot make a real +/// relative path unusable: a directory that is a bundle is always read as one. +/// +/// The two readings' refusals are joined rather than chosen between, because by the +/// time both have failed io-cli genuinely does not know which the operator meant, +/// and picking one to report is picking which half of the answer to hide. +/// +/// `markets` is a closure so that an ordinary `plugin add ./bundles/rust-review` +/// walks no marketplace at all — the tree is only read once the path has already +/// failed. +pub fn chosen( + dir: &Path, + markets: impl FnOnce() -> Vec, + text: &str, +) -> Result { + match crate::pluginview::refusal(dir) { + None => Ok(dir.to_path_buf()), + Some(refused) => { + locate(&markets(), text).map_err(|missing| format!("{refused} — {missing}")) + } + } +} + +/// Every bundle whose name or description carries `text`, in [`markets`]' order. +/// +/// **Across every marketplace, and F5's named sabotage is stopping at the first +/// one.** An operator adds a second marketplace precisely because the first did not +/// have what they needed, so a search that never reaches it answers "nothing" about +/// the one repository they fetched for this. +/// +/// Matched case-insensitively over the label and the description, because a +/// description is a sentence somebody wrote for a human and the name is a +/// lowercase identifier — a case-sensitive search over the pair finds one or the +/// other and never both. +/// +/// One finished line each, and the first field is the **qualified spelling**, which +/// is exactly what `plugin add` takes: the answer to "what is out there" is then +/// also the thing to paste, with no second lookup to work out which marketplace the +/// hit came from. +#[must_use] +pub fn matching(markets: &[Market], text: &str) -> Vec { + let needle = text.to_lowercase(); + markets + .iter() + .flat_map(|market| market.bundles.iter().map(move |bundle| (market, bundle))) + .filter(|(_, bundle)| { + bundle.label().to_lowercase().contains(&needle) + || bundle + .description + .as_deref() + .is_some_and(|said| said.to_lowercase().contains(&needle)) + }) + .map(|(market, bundle)| format!("{}@{} · {}", bundle.label(), market.name(), bundle.line())) + .collect() +} + // --- the three that reach the operator's home --------------------------------- /// Fetch `named` into `~/.io-cli/marketplaces`. diff --git a/tests/marketplace.rs b/tests/marketplace.rs index 47a773e..58609fd 100644 --- a/tests/marketplace.rs +++ b/tests/marketplace.rs @@ -18,7 +18,8 @@ use std::path::{Path, PathBuf}; use io_cli::fetch::Named; use io_cli::manage::{self, MarketVerb, PluginVerb, Request}; -use io_cli::marketplace::{self, Went}; +use io_cli::marketplace::{self, Market, Went}; +use io_harness::config::Scope; /// The token slice a shell hands `io`, spelled the way a test can read. fn argv(words: &[&str]) -> Vec { @@ -295,6 +296,16 @@ fn a_marketplace_refusal_names_what_is_accepted() { ("plugin marketplace add a/b --scope user", "no flags at all"), // And the list verb takes nothing. ("plugin marketplace list extra", "takes no arguments"), + // The surface's verb list carries the two verbs 0.29.0 adds as well. A + // refusal that omits one tells an operator at a terminal that it does not + // exist, which is the trip to the documentation these refusals exist to + // save — `src/manage.rs`'s `verbs` says so in its own note. + ("plugin wat", "install"), + ("plugin wat", "search"), + ("plugin", "search"), + // And `search` says what it wants, since it is the one verb here whose + // argument is neither a name nor a path. + ("plugin search", "some text to look for"), ] { let refusal = manage::parse(&manage::tokens(line)).expect_err(line); assert!( @@ -699,3 +710,252 @@ fn a_fetch_that_did_not_clone_says_which_of_the_three_it_was() { failed.said, ); } + +// --- F4 and F5: a bundle by name, and a search across every marketplace -------- + +/// The fixture both read: two marketplaces, one name they both carry, and one +/// bundle only the second holds. +/// +/// **`markets` sorts by owner, so `otherowner/mirror` is the first marketplace and +/// `zeroonething/ultraship` is the second.** Everything F5 has to find lives in the +/// second one and everything F4 installs by a bare name lives there too — which is +/// what makes "read only the first marketplace" and "resolve to the first match" +/// fail rather than pass by accident. +fn two(root: &Path) -> Vec { + let mirror = root.join("otherowner").join("mirror"); + manifest( + &mirror.join("plugins").join("shared"), + "name = \"shared\"\ndescription = \"A bundle both marketplaces carry.\"\n", + ); + // A marketplace that is itself one bundle, which is also what gives + // `ultraship@ultraship` a repository name to be qualified by. + let ultraship = root.join("zeroonething").join("ultraship"); + manifest( + &ultraship, + "name = \"ultraship\"\ndescription = \"Release choreography for io-cli.\"\n", + ); + manifest( + &ultraship.join("plugins").join("shared"), + "name = \"shared\"\ndescription = \"The other copy.\"\n", + ); + marketplace::markets(root) +} + +/// **F4 — a bundle is installed by name, and `install` is `add`.** +/// +/// The three spellings resolve to one directory and are written as one +/// `[[plugin]]` entry, through `pluginview::add` — the edit `/plugin add` already +/// had. A second writer for the named form is the thing this test would not catch +/// and `src/manage.rs`'s own rule forbids; what it does catch is the three +/// spellings drifting apart. +/// +/// Sabotage: read the *shape* of the word — a `/`, a leading `.` — to tell a path +/// from a name. The `shared` directory below is a real bundle whose name two +/// marketplaces also carry, and under that rule it resolves to somebody else's +/// code. +#[test] +fn f4_a_bundle_is_installed_by_name_and_install_is_add() { + let store = tempfile::tempdir().expect("a marketplaces directory"); + let work = tempfile::tempdir().expect("a workspace"); + let markets = two(store.path()); + + // Compared against the value both must produce rather than one parse against + // another: `parse(X) == parse(X)` is the vacuity `tests/manage.rs` records + // having shipped, and two spellings of one verb is exactly where it hides. + let expected = Request::Plugin(PluginVerb::Add { + path: "ultraship".into(), + scope: Scope::User, + }); + for line in ["plugin add ultraship", "plugin install ultraship"] { + assert_eq!( + manage::parse(&manage::tokens(line)).expect(line), + expected, + "`/{line}` must be the same request as the other spelling, and this one", + ); + } + // And the composer routes both words, or the slash door never reaches the + // parse at all and the criterion is about a door nobody can open. + for line in ["plugin install ultraship", "plugin search release"] { + assert!( + matches!( + io_cli::commands::parse(line, &io_cli::keys::Keys::default(), &io_cli::theme::DARK), + io_cli::commands::Action::Manage(_), + ), + "`/{line}` must reach `manage::parse`, or the slash form and `io …` can disagree", + ); + } + + // One directory, whichever way it is spelled: bare, by repository, and fully + // qualified. + let bundle = store.path().join("zeroonething").join("ultraship"); + let spellings = [ + "ultraship", + "ultraship@ultraship", + "ultraship@zeroonething/ultraship", + ]; + for query in spellings { + assert_eq!( + marketplace::locate(&markets, query).expect(query), + bundle, + "`{query}` named a different directory", + ); + } + + // And one entry. The bytes rather than the paths, because "the same file" is + // the criterion and a path comparison would pass over two edits that render + // differently. + let written: Vec = spellings + .iter() + .map(|query| { + let dir = marketplace::locate(&markets, query).expect(query); + let edit = io_cli::pluginview::add(&io_cli::pluginview::declared(work.path(), &dir)); + io_cli::edit::apply("", std::slice::from_ref(&edit)).expect("the entry is written") + }) + .collect(); + assert!( + written[0].contains(&format!("path = {:?}", bundle.display().to_string())), + "the entry names the bundle inside the marketplace: {}", + written[0], + ); + assert_eq!(written[0], written[1], "`@` wrote a different entry"); + assert_eq!( + written[0], written[2], + "`@/` wrote a different entry", + ); + + // **The discrimination rule, both ways round.** A directory carrying a + // manifest is a path — even when its own name is one two marketplaces carry, + // which is the case a rule keyed on the word's shape gets wrong. + let here = work.path().join("shared"); + manifest(&here, "name = \"shared\"\n"); + assert_eq!( + marketplace::chosen(&here, || markets.clone(), "shared").expect("a real path is a path"), + here, + "a directory carrying a manifest was read as a name and resolved elsewhere", + ); + + // A word that is neither is refused as both, because io-cli genuinely does not + // know which was meant and reporting one half hides the other. + let plain = work.path().join("nope"); + std::fs::create_dir_all(&plain).expect("a directory that is not a bundle"); + let refusal = marketplace::chosen(&plain, || markets.clone(), "nope") + .expect_err("neither reading of `nope` holds"); + assert!( + refusal.contains(io_cli::pluginview::MANIFEST), + "the path reading must still name the file it looked for: {refusal}", + ); + assert!( + refusal.contains("`shared@otherowner/mirror`"), + "the name reading must say what IS here: {refusal}", + ); +} + +/// **F4's named sabotage — a bare name two marketplaces carry is refused.** +/// +/// Two marketplaces holding one name hold two different repositories' code. +/// Resolving to whichever the walk reached first installs something the operator +/// did not choose, silently, under a name they believed meant one thing. +/// +/// Sabotage: take the first match. Then `locate` answers `Ok` here and this test +/// fails on the refusal it did not get. +#[test] +fn f4_a_name_two_marketplaces_carry_is_refused_by_both_spellings() { + let store = tempfile::tempdir().expect("a marketplaces directory"); + let markets = two(store.path()); + + let refusal = + marketplace::locate(&markets, "shared").expect_err("two marketplaces carry `shared`"); + for spelling in [ + "`shared@otherowner/mirror`", + "`shared@zeroonething/ultraship`", + ] { + assert!( + refusal.contains(spelling), + "the refusal must spell every way of choosing, or the operator has to go \ + and look them up: {refusal}", + ); + } + + // Qualified, it resolves — and to the marketplace that was named rather than + // to the first, which is the same sabotage read from the other side. + assert_eq!( + marketplace::locate(&markets, "shared@zeroonething/ultraship").expect("the qualifier"), + store + .path() + .join("zeroonething") + .join("ultraship") + .join("plugins") + .join("shared"), + ); + assert_eq!( + marketplace::locate(&markets, "shared@mirror").expect("the repository alone qualifies"), + store + .path() + .join("otherowner") + .join("mirror") + .join("plugins") + .join("shared"), + ); + + // A name nothing holds says what is here rather than "not found": a bare + // refusal over clones fetched weeks ago sends an operator into each of them. + let missing = marketplace::locate(&markets, "rust-review").expect_err("nothing is called that"); + assert!( + missing.contains("`ultraship@zeroonething/ultraship`"), + "the refusal must list what is available, qualified: {missing}", + ); + + // And with no marketplace at all it says so rather than listing nothing. + let empty = marketplace::locate(&[], "ultraship").expect_err("there is nothing here"); + assert!( + empty.contains("plugin marketplace add"), + "the refusal must say how to get one: {empty}", + ); +} + +/// **F5 — `plugin search` reads across every added marketplace.** +/// +/// Sabotage: search only the first marketplace. `choreography` is in the second +/// one and in no other, so the first assertion below fails on an empty result. +/// Second sabotage, the quieter one: search names and not descriptions — the same +/// assertion fails, because that word is only in a description. +#[test] +fn f5_search_reads_across_every_added_marketplace() { + let store = tempfile::tempdir().expect("a marketplaces directory"); + let markets = two(store.path()); + assert_eq!( + markets[0].name(), + "otherowner/mirror", + "the ordering this test's sabotage turns on", + ); + + let hits = marketplace::matching(&markets, "choreography"); + assert_eq!( + hits.len(), + 1, + "a word in the second marketplace's description must be found: {hits:?}", + ); + assert!( + hits[0].starts_with("ultraship@zeroonething/ultraship"), + "the hit names its marketplace, in the spelling `plugin add` takes: {}", + hits[0], + ); + // Folded on both sides: a description is a sentence somebody wrote and a name + // is a lowercase identifier, and a case-sensitive search finds one or the + // other and never both. + assert_eq!(marketplace::matching(&markets, "CHOREOGRAPHY"), hits); + + // A name both marketplaces carry is two hits and not one: they are two + // different bundles, and merging them is the ambiguity F4 refuses, hidden. + let both = marketplace::matching(&markets, "shared"); + assert_eq!(both.len(), 2, "{both:?}"); + assert!( + both[0].contains("otherowner/mirror") && both[1].contains("zeroonething/ultraship"), + "each hit is named by the marketplace that holds it, in `markets` order: {both:?}", + ); + + assert!( + marketplace::matching(&markets, "nothing-is-called-this").is_empty(), + "a query nothing matches must answer nothing rather than everything", + ); +} From cf7108ac54202a879c6f6a72e91ec9bb3b0a1494 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 19:04:27 +0530 Subject: [PATCH 09/12] feat(marketplace): disclose what a bundle may do before it is allowed to do it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bundle from a marketplace is code from a stranger contributing to six subsystems at once. Until this release every bundle came from a directory the operator had read themselves. io-harness 0.70.0 publishes no loader that takes a directory — `load_one` is private and `Plugins::load` is `pub(crate)` — so the disclosure is not a preview io-cli parses. The entry is written `enabled = false`, the configuration is re-discovered, and io-harness reads, parses, validates and trust-checks the bundle for real, handing it back through `Plugins::disabled()` as a `Plugin` that contributes nothing. What the operator sees is what the harness parsed: namespaced agent names, the MCP servers, the policy layers, the skills and template directories. A bundle the harness refuses is refused at that point, before consent, in its own words. Consent flips one key through `Edit::set("plugin[N].enabled", "true")` — the indexed path `split_path` already peels — so the file is byte-identical but for `false` becoming `true`. Declining leaves the bundle declared, off, and listed under its own mark. **Hooks are read from the manifest, and only to be shown.** There is no `Plugin::hooks()` and `Hook` is `pub(crate)` (io-harness#223), so `contributions()` answers the bare word `hooks` — and hooks are the contribution that runs programs. Consenting on that word is consenting to argv nobody named. The reader goes when the accessor lands. **The marketplace pane used to prefill `/plugin add `, and that took the path reading.** A stranger's bundle would have gone straight on through the product's primary install flow, disclosure skipped. It prefills the qualified name now. Also fixes the confirmation this surface had drifted on: the title said `Stop loading {id}?` over a row that had just offered to stop *declaring* it, and it used a bare "leave it" literal with `index == 1` rather than `store::LEAVE_IT` and `store::acts` — the one binary confirmation here that had come off the rule the library exists to hold. --- src/main.rs | 324 ++++++++++++++++++++++++++++++--- src/manage.rs | 31 +++- src/marketplace.rs | 225 ++++++++++++++++++++++- src/pluginview.rs | 206 +++++++++++++++++++-- tests/marketplace.rs | 416 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 1147 insertions(+), 55 deletions(-) diff --git a/src/main.rs b/src/main.rs index 41e44c3..26c582e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1757,19 +1757,34 @@ async fn loop_over Result>( Pick::PluginEntry { id, bundle, + enabled, action_at, } => { // Every row but one is a fact to read. if index == *action_at { + // **One act, one wording, and the flag decides it + // once.** The row this descends from already says + // "stop declaring this bundle" over a switched-off + // one, because it is not loading and offering to + // stop that names an act nobody can take — and + // until 0.29.0 the confirmation it opened still + // said `Stop loading {id}?`. Two labels for one + // act, one of them false about the bundle in front + // of the operator, on the screen where they commit. + let verb = if *enabled { "loading" } else { "declaring" }; match io_cli::pluginview::declared_at(session.root(), bundle) { Some((scope, at)) => { descended = Some(( Picker::new( - format!("Stop loading {id}?"), + format!("Stop {verb} {id}?"), vec![ - Row::new("leave it".to_string()), + // `store::LEAVE_IT` rather than + // a literal, so the label and + // the `store::acts` test of it + // cannot drift. + Row::new(io_cli::store::LEAVE_IT.to_string()), Row::with_detail( - "stop loading it".to_string(), + format!("stop {verb} it"), format!( "removes `plugin[{at}]` from the {} \ scope; the directory is left alone", @@ -1814,9 +1829,10 @@ async fn loop_over Result>( scope, index: at, } => { - // Row 0 is "leave it", which is the default and does - // nothing — the same shape `/skills`' toggle uses. - if index == 1 { + // `store::acts` rather than `index == 1`: the decision + // lives in the library, where a test can sabotage it, + // and row 0 declines whatever it is called. + if io_cli::store::acts(index) { let edit = io_cli::pluginview::remove(*at); match io_cli::configure::write(session.root(), *scope, &[edit]) { Ok(()) => { @@ -1838,6 +1854,55 @@ async fn loop_over Result>( } } } + // The consent a marketplace install waits on. `at` and not + // `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, + } => { + 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)], + ) { + Ok(()) => app.record( + Tone::Success, + format!( + "{id} is switched on; 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. + 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", + ), + ); + } + } // The two verb rows, and the only rows on this surface that // are not bundles. They are checked first because both sit // past the end of both lists and every branch below indexes @@ -1972,10 +2037,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. 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 @@ -2003,6 +2076,10 @@ async fn loop_over Result>( Pick::PluginEntry { id: plugin.id.clone(), bundle: plugin.root.clone(), + // Carried, so the confirmation this + // descends into words the act the same way + // the row above it did. + enabled: plugin.enabled, action_at, }, )); @@ -2145,16 +2222,27 @@ async fn loop_over Result>( ), ); // **Declared through the verb that already declares - // a bundle, not through a second writer.** A - // marketplace directory is a directory like any - // other, so `plugin add ` writes the entry — - // and it is the same parse, the same - // `pluginview::refusal` and the same - // `configure::write` every other declaration goes - // through. A path prefilled here is one the - // operator has just read on the row above it. - app.composer - .set(&format!("/plugin add {}", bundle.dir.display())); + // a bundle, not through a second writer.** It is + // the same parse, the same `pluginview::refusal` + // and the same `configure::write` every other + // declaration goes through. + // + // **By its qualified name and not by its path**, + // which is `marketplace::matching`'s own spelling + // and always resolves. That is not cosmetic: the + // name is what `marketplace::chosen` reads as + // `Chosen::Held`, and `Held` is what makes the + // entry `enabled = false` and earns the operator + // the disclosure before a stranger's bundle + // contributes to six subsystems. Prefilling the + // directory would take the path reading, which + // exists for a directory the operator wrote + // themselves, and switch this one straight on. + app.composer.set(&format!( + "/plugin add {}@{}", + bundle.label(), + market.name(), + )); } } Pick::MarketplaceRemove { named } => { @@ -3820,17 +3908,128 @@ async fn loop_over Result>( } Err(error) => app.record(Tone::Error, error), } - 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 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, + }, + )); + } + } + } // **The preflight after the write, never // instead of it.** A server the policy will // refuse is still written — the report is a @@ -7585,11 +7784,34 @@ enum Pick { /// /// `action_at` is where the removal row sits, recorded by the code that built /// the rows rather than recomputed here. + /// + /// `enabled` is `pluginview::Listed::enabled`, carried so the confirmation + /// below words the act the same way the row that opened it did. A bundle + /// declared `enabled = false` is not loading, so *stop loading* names an act + /// nobody can take — the row said "stop declaring this bundle" from 0.29.0 and + /// the confirmation went on saying `Stop loading {id}?` until this flag + /// existed to reach it. PluginEntry { id: String, bundle: std::path::PathBuf, + enabled: bool, action_at: usize, }, + /// The consent a marketplace install waits on: switch the declared bundle on. + /// + /// **Two rows and no facts.** Everything io-harness made of the bundle was + /// recorded into the scrollback before this opened, because every row of a + /// 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 { + id: String, + scope: io_harness::config::Scope, + index: usize, + }, /// The second half of removing a bundle: which entry, and are you sure. /// /// **Two steps rather than one, for the reason [`Pick::SkillToggle`] gives** — @@ -7793,6 +8015,56 @@ 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) } diff --git a/src/manage.rs b/src/manage.rs index 79bd716..f6f123c 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -127,6 +127,13 @@ pub enum PluginVerb { /// Still a `PathBuf` for the one word, because the reading that wins in every /// existing case is the path and a `String` here would make the common verb /// convert at both call sites to serve the rarer one. + /// + /// **Which reading it was decides whether the entry is written on.** A + /// directory the operator typed is declared on, as it has been since 0.28.0; a + /// bundle out of a marketplace is a stranger's code and is declared + /// `enabled = false` so io-harness reads, parses and trust-checks it before + /// anything of it reaches a turn. See [`plan`] and + /// [`crate::marketplace::Chosen`]. Add { path: PathBuf, scope: Scope }, /// Every declared bundle, loaded and refused. List, @@ -462,7 +469,8 @@ 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::remove`, `Edit::set` and +/// `servers::remove`, `pluginview::add`, `pluginview::add_off`, +/// `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. @@ -532,16 +540,29 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { // // `display()` gives back the word as typed: `path` is a `PathBuf` made // out of one token and is never rendered from components. - let dir = crate::marketplace::chosen( + let chosen = crate::marketplace::chosen( &resolve(root, path), || crate::marketplace::installed().unwrap_or_default(), &path.display().to_string(), )?; + let written = crate::pluginview::declared(root, chosen.dir()); Plan { scope: *scope, - edits: vec![crate::pluginview::add(&crate::pluginview::declared( - root, &dir, - ))], + // **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) + }], } } Request::Plugin(PluginVerb::Remove { path }) => { diff --git a/src/marketplace.rs b/src/marketplace.rs index b00106a..7aaa98e 100644 --- a/src/marketplace.rs +++ b/src/marketplace.rs @@ -62,7 +62,54 @@ //! not hold what was wanted. //! //! Nothing here writes: [`chosen`] answers with a directory and the entry is -//! written by [`crate::pluginview::add`], the edit `/plugin add` already had. +//! written by [`crate::pluginview`], the edits `/plugin add` already had. +//! +//! # A marketplace install discloses before it enables, 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. +//! +//! **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. +//! +//! **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. +//! +//! 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. +//! +//! # The hooks are read here, and only to be shown +//! +//! 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. //! //! # Removing a marketplace removes a clone and nothing else //! @@ -664,13 +711,181 @@ pub fn chosen( dir: &Path, markets: impl FnOnce() -> Vec, text: &str, -) -> Result { +) -> Result { match crate::pluginview::refusal(dir) { - None => Ok(dir.to_path_buf()), - Some(refused) => { - locate(&markets(), text).map_err(|missing| format!("{refused} — {missing}")) + None => Ok(Chosen::Path(dir.to_path_buf())), + Some(refused) => locate(&markets(), text) + .map(Chosen::Held) + .map_err(|missing| format!("{refused} — {missing}")), + } +} + +/// Which reading of the word won, and the directory it named. +/// +/// **The reading is the disclosure rule, and it is recorded here so that nothing +/// downstream has to ask the question twice.** A directory the operator typed is a +/// directory the operator has; a bundle resolved out of a marketplace is a +/// stranger's code that no one on this machine has read. The first is declared on, +/// which is `/plugin add`'s behaviour since 0.28.0 and is not a thing to change +/// under someone who typed a path they trust; the second is declared +/// `enabled = false` and disclosed before it is switched on. See the module docs. +/// +/// The distinction rides on [`chosen`]'s own answer rather than on the shape of +/// the word, for [`chosen`]'s own reason: a rule keyed on a `/` or a leading `.` +/// would make one word disclose in one working directory and not in another. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Chosen { + /// A directory on this machine, named by the operator. + Path(PathBuf), + /// A bundle a marketplace holds, resolved by name. + Held(PathBuf), +} + +impl Chosen { + /// The directory either reading named. + #[must_use] + pub fn dir(&self) -> &Path { + match self { + Self::Path(dir) | Self::Held(dir) => dir, } } + + /// Whether declaring it owes the operator a disclosure before it loads. + /// + /// One method rather than a `matches!` at the call site, so the rule has one + /// home and a sabotage of it fails everywhere at once. + #[must_use] + pub fn discloses(&self) -> bool { + matches!(self, Self::Held(_)) + } +} + +/// 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. +/// +/// **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}")); + let event = at("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() +} + +/// What a just-declared, switched-off bundle turned out to be. +/// +/// 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 +/// operator will see in a trace, and never with the directory it happened to sit +/// in. +#[derive(Debug, Clone, PartialEq, Eq)] +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, +} + +/// 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. +/// +/// 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. +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() + ) + })?; + Ok(Disclosure { + id: listed.id.clone(), + said: crate::pluginview::detail(listed, width, glyphs, hooks) + .into_iter() + .map(|row| match row.detail { + Some(detail) => format!("{}{}{detail}", row.label, glyphs.separator), + None => row.label, + }) + .collect(), + }) } /// Every bundle whose name or description carries `text`, in [`markets`]' order. diff --git a/src/pluginview.rs b/src/pluginview.rs index 8c2fd7b..df835b9 100644 --- a/src/pluginview.rs +++ b/src/pluginview.rs @@ -61,16 +61,26 @@ //! [`Listed::enabled`] so a switched-off bundle's skills are not offered to a //! model that cannot reach them. //! -//! # Hooks cannot be listed, and this file says so on screen +//! # Hooks cannot be listed *from here*, and this file says so on screen //! -//! There is no `Plugin::hooks()` and `Hook` is `pub(crate)`. A bundle's hooks are -//! applied — [`io_harness::Plugins::apply_to_hooks`] installs them — and there is -//! no API by which io-cli 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, and [`detail`] draws exactly that: 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. +//! 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. +//! +//! **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. //! //! # The names drawn here are already namespaced, and that is the point //! @@ -461,7 +471,26 @@ pub fn rows(view: &View, width: u16, glyphs: &Glyphs) -> Vec { /// parses and validates a disabled bundle in full — so the groups are true about /// the *directory* and false about the *session*, and the row above them is what /// makes the pane say which. See [`Listed::enabled`]. -pub fn detail(plugin: &Listed, width: u16, glyphs: &Glyphs) -> Vec { +/// +/// # `hooks` is the one group this module cannot read for itself +/// +/// 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 { // 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); @@ -483,7 +512,24 @@ pub fn detail(plugin: &Listed, width: u16, glyphs: &Glyphs) -> Vec { out.push(Row::heading("mcp servers")); out.extend(plugin.servers.iter().map(|id| Row::new(id.clone()))); } - if plugin.contributions.contains(&"hooks") { + if !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. + out.push(Row::heading("hooks")); + out.extend(hooks.iter().map(|(event, command)| { + Row::with_detail( + event.clone(), + fit( + command, + room.saturating_sub(event.chars().count() + 2), + glyphs, + ), + ) + })); + } 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, @@ -557,6 +603,99 @@ pub fn add(dir: &Path) -> crate::edit::Edit { ) } +/// What writing an `enabled` key into a `[[plugin]]` costs an older binary. +/// +/// **`enabled` on a `[[plugin]]` is io-harness 0.70.0's, and 0.69.0 does not +/// ignore it — it refuses the whole file.** The `[[mcp]]` case is the opposite and +/// that is exactly why this has to be said out loud: an operator who has seen a +/// forward-compatible key before will assume this one is too. A shared `io.toml` +/// or a second machine on the older binary loses every setting in the file, not +/// the bundle. +pub const OLDER_BINARY: &str = + "this writes an `enabled` key into a `[[plugin]]` entry, which is io-harness 0.70.0's: an \ + 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`]'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`]. +/// +/// **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()) + ), + ) +} + +/// The edit that switches the `index`-th declared bundle on. +/// +/// **One key and no other byte of the file.** [`crate::edit::Edit::set`] resolves +/// `plugin[N].enabled` through the same splitter every other path goes through — +/// `plugin[N]` is the section, `enabled` is the key — and replaces the value's own +/// span, so the path this entry declares, every sibling entry, every comment and +/// every unrelated section come through untouched. Rewriting the entry instead +/// would be the same configuration expressed in different bytes, and an operator +/// who diffs their `io.toml` after consenting would see io-cli reformat a file +/// they never asked it to touch. +/// +/// `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 enable(index: usize) -> crate::edit::Edit { + crate::edit::Edit::set(format!("plugin[{index}].enabled"), "true") +} + +/// 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; + }; + last = Some((index, PathBuf::from(raw.trim().trim_matches('"')))); + } + 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. /// @@ -925,7 +1064,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 @@ -946,7 +1085,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"), "{}", @@ -1026,7 +1165,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); @@ -1055,7 +1194,7 @@ mod tests { #[test] fn hooks_are_declared_as_unlistable_rather_than_omitted() { - let rows = detail(&listed(), 120, &UNICODE); + let rows = detail(&listed(), 120, &UNICODE, &[]); let position = rows .iter() .position(|row| row.label == "hooks") @@ -1074,7 +1213,7 @@ mod tests { // never drawn over a bundle it is not true of. let mut quiet = listed(); quiet.contributions = vec!["skills"]; - assert!(detail(&quiet, 120, &UNICODE) + assert!(detail(&quiet, 120, &UNICODE, &[]) .iter() .all(|row| row.label != "hooks")); } @@ -1094,7 +1233,7 @@ mod tests { servers: 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"); } @@ -1111,6 +1250,39 @@ 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. + #[test] + fn the_disclosing_edits_declare_off_in_one_entry_and_consent_with_one_key() { + assert_eq!( + add_off(Path::new("bundles/rust-review")), + crate::edit::Edit::append("plugin", "path = \"bundles/rust-review\"\nenabled = false") + ); + 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", + ); + 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] fn a_windows_path_is_escaped_rather_than_handed_over_as_escapes() { assert_eq!( diff --git a/tests/marketplace.rs b/tests/marketplace.rs index 58609fd..90e76fc 100644 --- a/tests/marketplace.rs +++ b/tests/marketplace.rs @@ -828,9 +828,11 @@ fn f4_a_bundle_is_installed_by_name_and_install_is_add() { // which is the case a rule keyed on the word's shape gets wrong. let here = work.path().join("shared"); manifest(&here, "name = \"shared\"\n"); + let read_as = + marketplace::chosen(&here, || markets.clone(), "shared").expect("a real path is a path"); assert_eq!( - marketplace::chosen(&here, || markets.clone(), "shared").expect("a real path is a path"), - here, + read_as, + marketplace::Chosen::Path(here), "a directory carrying a manifest was read as a name and resolved elsewhere", ); @@ -959,3 +961,413 @@ fn f5_search_reads_across_every_added_marketplace() { "a query nothing matches must answer nothing rather than everything", ); } + +// --- F6 to F9 and F12: disclosure before load --------------------------------- +// +// 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. +// +// 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. + +/// A marketplace holding one bundle whose manifest name, directory name and +/// contribution names are all deliberately different. +/// +/// `plugins/rust` on disk, `rust-review` in the manifest, `reviewer` as the agent +/// — which io-harness namespaces to `rust-review__reviewer` at load. That third +/// spelling is F6's sabotage arm: a disclosure rendered out of the manifest says +/// `reviewer`, and `reviewer` is a name the operator will never see again. +fn holding(store: &Path) -> (Vec, PathBuf) { + let dir = store + .join("zeroonething") + .join("ultraship") + .join("plugins") + .join("rust"); + std::fs::create_dir_all(dir.join("skills")).expect("the skills directory"); + manifest( + &dir, + "name = \"rust-review\"\ndescription = \"Everything our Rust reviews need.\"\n\ + version = \"1.2.0\"\nskills = \"skills\"\n\n\ + [[agent]]\nname = \"reviewer\"\nmodel = \"cheap-model\"\ndeny_write = true\n\n\ + [policy]\nlayers = [\n { name = \"no-secrets\", rules = [{ act = \"write\", \ + effect = \"deny\", pattern = \"secrets/**\" }] },\n]\n", + ); + (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 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 { + let text = io_cli::edit::apply( + "", + &[io_cli::pluginview::add_off(&io_cli::pluginview::declared( + work, dir, + ))], + ) + .expect("the disclosing entry applies"); + std::fs::write(work.join(io_harness::config::LOCAL_FILE), &text).expect("the configuration"); + text +} + +/// **F6 — the install discloses before it enables, and the disclosure is +/// io-harness's parse.** +/// +/// 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. +/// +/// Sabotage: render the contributions from the manifest. The agent is called +/// `reviewer` there and `rust-review__reviewer` after io-harness has loaded 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. +#[test] +fn f6_the_install_declares_off_and_discloses_the_harness_s_own_parse() { + let store = tempfile::tempdir().expect("a marketplaces directory"); + let work = tempfile::tempdir().expect("a workspace"); + let (markets, dir) = holding(store.path()); + + // The name reading, decided against the disk by the one function that decides + // it. The word names no directory here, so it can only be a name. + let chosen = marketplace::chosen( + &work.path().join("rust-review"), + || markets.clone(), + "rust-review", + ) + .expect("the marketplace holds it"); + assert_eq!(chosen, marketplace::Chosen::Held(dir.clone())); + 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", + ); + // 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", + ); + + let text = install(work.path(), &dir); + 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}", + ); + + // 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"); + assert_eq!(disclosure.id, "rust-review"); + let said = disclosure.said.join("\n"); + assert!( + said.contains("rust-review__reviewer"), + "the agent is not named as io-harness namespaced it, so the operator is \ + consenting to a name they will never see again: {said}", + ); + assert!( + said.contains("rust-review__no-secrets"), + "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}", + ); +} + +/// **F6, the other half — a bundle io-harness refuses is refused at this point, +/// before consent, in its 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. +/// +/// 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. +#[test] +fn f6_a_bundle_the_harness_refuses_is_refused_before_consent() { + 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); + + let refusal = marketplace::disclosure(&view, &dir, &[], 400, &io_cli::glyphs::UNICODE) + .expect_err("io-harness refused it"); + assert!( + refusal.contains(io_cli::pluginview::MANIFEST), + "the refusal is not io-harness's own sentence about the file it looked \ + for: {refusal}", + ); + assert_eq!( + view.refused.len(), + 1, + "the refusal arrived somewhere other than the bucket `/plugin` draws", + ); +} + +/// **F7, the decline half — declining leaves the bundle declared, off, and +/// visible.** +/// +/// 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: 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. +#[test] +fn f7_declining_leaves_the_bundle_declared_off_and_listed() { + 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. + assert_eq!( + io_cli::edit::value_at(&text, "plugin[0].enabled").as_deref(), + Some("false"), + ); + 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), + ); + + let driver = code_of("src/main.rs"); + assert_eq!( + driver.matches("pluginview::remove(").count(), + 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.", + driver.matches("pluginview::remove(").count(), + ); +} + +/// **F8 — consent flips exactly one key.** +/// +/// 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 +/// 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() { + let before = "# the bundles this checkout loads\n\ + [[plugin]]\npath = \"bundles/first\" # ours\n\n\ + [run]\nmax_steps = 30\n\n\ + [[plugin]]\npath = \"bundles/rust-review\"\nenabled = false\n"; + let after = + io_cli::edit::apply(before, &[io_cli::pluginview::enable(1)]).expect("the consent applies"); + + assert_eq!( + after, + before.replace("enabled = false", "enabled = true"), + "consent changed a byte outside the `enabled` value it was asked for", + ); + // Spelled out as well as compared, so a failure says which fact went. + for kept in [ + "# the bundles this checkout loads", + "path = \"bundles/first\" # ours", + "[run]\nmax_steps = 30", + ] { + assert!(after.contains(kept), "`{kept}` did not survive:\n{after}"); + } + assert!(!after.contains("enabled = false")); + + // And the index is the file's array position, not a row number: flipping the + // wrong one is silent. + assert!( + io_cli::edit::apply(before, &[io_cli::pluginview::enable(0)]) + .expect("entry 0 has no `enabled` key, so one is written into it") + .contains("path = \"bundles/first\" # ours\nenabled = true") + ); +} + +/// **F9 — the disclosure names each hook's event and command.** +/// +/// 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. +/// +/// 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. +#[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"); + manifest( + &dir, + "name = \"guard\"\n\n\ + [[hook]]\non = [\"tool_call\"]\nrun = [\"cargo\", \"fmt\"]\n\n\ + [[hook]]\nat = \"before_tool\"\nrun = [\"./scripts/guard.sh\"]\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"); + assert!( + listed.contributions.contains(&"hooks"), + "io-harness did not report hooks at all: {:?}", + listed.contributions, + ); + + let named = io_cli::pluginview::detail(listed, 400, &io_cli::glyphs::UNICODE, &hooks); + let under_hooks: Vec<&str> = named + .iter() + .skip_while(|row| !(row.heading && row.label == "hooks")) + .skip(1) + .take_while(|row| !row.heading) + .map(|row| row.label.as_str()) + .collect(); + assert_eq!( + under_hooks.len(), + 2, + "the group under `hooks` is not one row per hook: {under_hooks:?}", + ); + + // 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, &[]); + 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", + ); +} + +/// **F12 — writing `enabled` into a `[[plugin]]` says what it costs a 0.69.0 +/// binary.** +/// +/// The `[[mcp]]` case is the opposite — an `enabled` key there is silently ignored +/// by the older parser — and that is exactly why this has to be said out loud: an +/// operator who has seen a forward-compatible key before will assume this one is +/// 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. +#[test] +fn f12_writing_enabled_says_what_it_costs_an_older_binary() { + let said = io_cli::pluginview::OLDER_BINARY; + for named in ["enabled", "[[plugin]]", "0.70.0", "0.69.0", "whole"] { + assert!( + said.contains(named), + "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(), + ); + assert_eq!( + driver.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", + ); + assert_eq!( + code_of("src/manage.rs") + .matches("pluginview::add_off(") + .count(), + 1, + "the disclosing entry has more than one writer", + ); + 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", + ); +} From ded0dc288c28676c9999b2fdfc04ade3a8675871 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 19:50:50 +0530 Subject: [PATCH 10/12] docs: marketplaces, the third plugin state, and four claims this release falsified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A README section for marketplaces — adding one, what it holds, installing by name, the ambiguity refusal, and the disclosure — plus the CHANGELOG entry and the configuration example. Four shipped claims went stale and are corrected rather than left: - `docs/config.example.toml` said there is "no registry it fetches from". Half of that is still true and is worth keeping: io operates no index, hosts nothing, curates nothing, ranks nothing. The other half stopped being true, so the sentence now says both. - The same file and the README described `/plugin` as two lists. There are three from 0.29.0, and the third — declared and switched off — is exactly the silent absence those paragraphs exist to argue against. - `README.md`'s `/plugin add` described a path chosen from a picker or typed. It takes a name now, and the path-vs-name rule is stated where somebody choosing between them will read it. - The hooks paragraph said io can never itemise them. It cannot from the typed API, and that is still what `/plugin` draws — but a marketplace install reads them from the manifest, because that is the one moment you are accepting a directory you have not read. The configuration example also gains the `enabled` key and what writing it costs an io-harness 0.69.0 binary, which is the one thing in this release an operator has to know before downgrading rather than after. The nightly rustdoc gate is clean — exit 0 with private items documented and broken links, bare URLs and invalid HTML all denied. First release in five where it found nothing. --- CHANGELOG.md | 65 ++++++++++++++++++++++ README.md | 115 +++++++++++++++++++++++++++++++++++++-- docs/config.example.toml | 32 ++++++++--- 3 files changed, 199 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5d9400..b5ed29b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,71 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.29.0] - 2026-08-29 + +A plugin can come from somewhere other than a directory you already had — and you +see what it is allowed to do before it is allowed to do it. + +**Marketplaces.** `/plugin marketplace add zeroonething/ultraship` resolves a +name to a git repository, clones it shallow into `~/.io-cli/marketplaces`, and +lists what it holds. A bundle inside one is any directory carrying a +`plugin.toml`, so there is no index file to write and none to disagree with the +directories it describes. `list` and `remove` are there too, and removing a +marketplace takes the clone and nothing else: a bundle you declared out of it +stays declared, and you are told beforehand which ones will stop loading. + +**Install by name.** `/plugin add ultraship`, or `ultraship@ultraship` where two +marketplaces carry that name, and `install` works as the same verb. `/plugin add` +still takes a path — a word is a path if it resolves to a directory carrying a +manifest, and a name otherwise, so it cannot mean different things in different +working directories. **A bare name two marketplaces carry is refused**, naming +both qualified spellings: taking the first match installs code you did not +choose. `/plugin search` reads names and descriptions across every marketplace +you have added. + +**What a bundle may do is shown before it is switched on.** A bundle contributes +skills, prompt templates, agents, MCP servers, hooks and policy layers to four +subsystems at once, and until now every one of them came from a directory you had +read yourself. The install writes the entry `enabled = false` first, so io-harness +reads, parses, validates and trust-checks the bundle for real and hands it back +contributing nothing — then shows you what it parsed, in the namespaced names you +will actually see. Saying yes flips one key. Saying no leaves the bundle +declared, off, and listed. A bundle io-harness would refuse is refused at that +point, in its own words, before you are asked anything. + +Hooks are the exception and are disclosed anyway. io-harness publishes no +accessor for them, so the disclosure reads the `[[hook]]` tables out of the +manifest and names each one's event and command. Consenting on the bare word +"hooks" is consenting to programs nobody named. Filed upstream as io-harness#223. + +**`/plugin` now shows a bundle you switched off.** io-harness 0.70.0 splits what +a configuration declared into three sets rather than two, and this interface was +reading two of them — so a bundle declared `enabled = false` appeared nowhere at +all, and a configuration whose bundles were all switched off was reported as +declaring none. It is listed under its own mark, with what switching it back on +would bring. + +**A run that failed its verification exits 6 again.** io-harness 0.70.0 closed +this project's own issue #212 and gave that run its own outcome, which — because +the enum is `#[non_exhaustive]` — quietly moved it out of the set `io exec` maps +to a ceiling and out of the set the gate retry acts on. Both are named again, so +a failing gate is retried and reported as what it is. + +### Dependencies + +- io-harness 0.69 → **0.70.0**. `McpServer` gains a required public `enabled` + field; `RunOutcome` gains `VerificationFailed`. One transitive crate added, + `quick-xml`, through the `documents` feature. The direct dependency set is + unchanged at ten names, and this release adds no HTTP client and no second + network path: a marketplace is fetched by running `git`. + +### Upgrading + +- **A `[[plugin]]` entry carrying `enabled` cannot be read by an io-cli built + against io-harness 0.69.0**, which refuses the whole file rather than ignoring + the key. io-cli says so at the moment it writes one. If you downgrade, remove + the `enabled` keys first. + ## [0.28.0] - 2026-08-29 Every list this interface can prune, it can now grow; and every setting whose diff --git a/README.md b/README.md index 7d95451..2233044 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ if this crate ever grows an HTTP client, a TLS stack, a database or a sandbox. - [Install](#install) · [First run](#first-run) · [Bringing your setup across](#bringing-your-setup-across) · [While it works](#while-it-works) - [Keys](#keys) · [Commands](#commands) · [What it costs](#what-it-costs) · [Configuration](#configuration) -- [Capability bundles](#capability-bundles) · [Hooks](#hooks) · [The fleet](#the-fleet) +- [Capability bundles](#capability-bundles) · [Marketplaces](#marketplaces) · [Hooks](#hooks) · [The fleet](#the-fleet) - [Pictures](#pictures) · [Documents](#documents) · [Background jobs](#background-jobs) - [Reading it without seeing it](#reading-it-without-seeing-it) · [Headless](#headless) - [What this release is not](#what-this-release-is-not) · [Platform support](#platform-support) · [Stability](#stability) @@ -1042,9 +1042,11 @@ path = "~/bundles/rust-review" ``` That is a declaration and never a scan. There is no directory io walks looking -for bundles, no registry it fetches from, and nothing that loads by being present -on disk — declaring one is the whole of installing one, and deleting the line is -the whole of removing one. +for bundles and nothing that loads by being present on disk — declaring one is +the whole of installing one, and deleting the line is the whole of removing one. +There is still no registry either; from 0.29.0 there are +[marketplaces](#marketplaces), which are repositories you name and clone, and +installing out of one writes exactly the entry above. One directory can hand over six kinds of thing at once: skills, prompt templates, `[[agent]]` definitions for a fan-out to draw children from, `[[mcp]]` servers, @@ -1065,6 +1067,15 @@ make is dropped, recorded, and otherwise silently absent while every other bundl loads. A bundle you believe is running can be gone for a week. This is where that week ends. +**From 0.29.0 there is a third list, for the same reason.** io-harness 0.70.0 +lets an entry say `enabled = false`, and a bundle written that way is read, +parsed and held to the whole trust rule while contributing nothing. It is a +state, not a failure — it is doing exactly what your file asked — so it is drawn +under its own mark with what switching it back on would bring, rather than +beside the ones you have to fix. It counts as declared, too: a configuration +whose bundles are all switched off is no longer reported as declaring none, which +is the sentence above inverted and just as misleading. + **And a bundle can be stopped from the same list.** The last row under a bundle's contributions removes its `[[plugin]]` entry, after a confirmation that names the scope and the entry it will take out. io finds that entry by matching the @@ -1105,7 +1116,74 @@ 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. +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. + +## Marketplaces + +**A marketplace is a git repository you name.** It is cloned into your own home +and walked for directories carrying a `plugin.toml`: + +``` +/plugin marketplace add zeroonething/ultraship +/plugin marketplace list +/plugin marketplace remove zeroonething/ultraship +``` + +The same words work from a shell — `io plugin marketplace add …` — through one +parse. There is no index file to write and none to disagree with the directories +it describes, and io operates no registry: it hosts nothing, curates nothing and +ranks nothing. The fetch is a `git` invocation and nothing else, so this adds no +HTTP client and no network path beside io-harness's. A machine with no `git` is +told so by name, and installing from a directory you already have is unaffected. + +Installing is the verb you already had: + +``` +/plugin add ultraship +/plugin install ultraship # the same verb, another word +/plugin add ultraship@zeroonething/ultraship # when two marketplaces carry it +/plugin search review +``` + +**A bare name two marketplaces carry is refused**, naming both qualified +spellings. They are two strangers' repositories, and installing whichever the +walk reached first is installing code you did not choose. + +**Removing a marketplace removes the clone and nothing else.** A bundle you +declared out of it keeps its `[[plugin]]` entry — a cache being emptied is not a +reason to undo a decision you made about your configuration. What io owes you +instead is the consequence, so it names the bundles that will stop loading before +it deletes anything. + +### What a bundle is allowed to do is shown before it is allowed to do it + +A bundle contributes to four subsystems at once, and until 0.29.0 every one you +declared came from a directory you had read. A marketplace removes that reading, +so the install puts it back. + +The entry is written **`enabled = false`** first. io-harness then reads, parses, +validates and trust-checks the bundle for real — there is no public way to ask it +about a directory that no configuration declares — and hands it back contributing +nothing at all. What you are shown is what io-harness parsed: the skills and +template directories, the agents, the MCP servers and the policy layers, in the +**namespaced** names you will actually see in a trace and type to spawn an agent. +A bundle io-harness would refuse is refused at that point, in its own words, +before you are asked anything. + +Saying yes changes one key. Saying no leaves the bundle declared, switched off, +and listed in `/plugin` — visible, and one keystroke from being switched on if +you change your mind. + +Hooks are named here too, read from the manifest, because `enabled = false` is +still not enough to make io-harness tell you what a hook runs. Consenting on the +bare word "hooks" is consenting to programs nobody named. + +**Writing `enabled` costs something and io says so at the time.** An io-cli built +against io-harness 0.69.0 does not know the key and refuses the *whole file* +rather than ignoring it. Remove the `enabled` keys before downgrading. ## Hooks @@ -1863,9 +1941,16 @@ io mcp edit semlith --timeout-secs 30 io mcp remove semlith io plugin add ./bundles/rust-review +io plugin add ultraship # or a name from a marketplace +io plugin install ultraship # the same verb +io plugin search review io plugin list io plugin remove ./bundles/rust-review +io plugin marketplace add zeroonething/ultraship +io plugin marketplace list +io plugin marketplace remove zeroonething/ultraship + io config get run.max_steps io config set run.max_steps 40 io config set app.io-cli.gates.command cargo test --all-features @@ -1909,6 +1994,13 @@ refuse one by name — the change goes to the file that declares the entry, and scope chosen here would aim a position counted in one file's array at another file's. +**`io plugin add ` from a marketplace declares the bundle and stops there.** +It writes the entry switched off, prints the disclosure to standard error, and +exits zero; switching it on is `/plugin`, in a session, where there is somebody to +ask. There is deliberately no `--yes`: consent to a stranger's code is not a flag +a script sets on your behalf. A path you already have is unaffected — `io plugin +add ./bundles/rust-review` behaves exactly as it did. + **`io config list` prints the origin column**, tab-separated after the value: `user`, `project`, `local`, or `default` for a key no file names. There is no flag to drop it. A value without the file that decided it is half an answer — that is @@ -2220,6 +2312,19 @@ entirely, is named outright — `/plugin add `, or `io plugin add ` a shell — and is refused by the same check rather than by a shallower one. See [Capability bundles](#capability-bundles). +**From 0.29.0 the same verb also takes a name.** `/plugin add ultraship` installs +a bundle out of a marketplace you have added, and `ultraship@zeroonething/ultraship` +says which one where two carry that name — a bare name two marketplaces carry is +refused rather than resolved, because taking the first match installs code you did +not choose. `install` is accepted as the same verb. **A word is a path if it +resolves to a directory carrying a manifest, and a name otherwise**: the rule asks +the disk rather than the spelling, so one word cannot mean a directory on a machine +that has one and a marketplace bundle on a machine that does not. + +Installing by name **declares the bundle switched off and shows you what it would +bring before it brings it** — see [Marketplaces](#marketplaces). Installing by +path does not: that directory is one you already have. + **`/profile`** switches to a named `[profile.]` for the session, and `--profile ` picks one for a single run without writing anything. diff --git a/docs/config.example.toml b/docs/config.example.toml index 3fde07d..f6f8cd4 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -285,17 +285,33 @@ net = "deny" # --- Capability bundles (0.20.0) -------------------------------------------- # # A bundle is a directory with a `plugin.toml` in it, and it is loaded because -# THIS FILE NAMES IT. There is no directory io walks looking for bundles and no -# registry it fetches from: a `[[plugin]]` entry is the whole of installing one, -# and deleting the entry is the whole of removing one. +# THIS FILE NAMES IT. There is no directory io walks looking for bundles: a +# `[[plugin]]` entry is the whole of installing one, and deleting the entry is +# the whole of removing one. +# +# THERE IS STILL NO REGISTRY, and from 0.29.0 that sentence needs its second +# half. `/plugin marketplace add /` clones a git repository into +# `~/.io-cli/marketplaces` and lists the directories in it that carry a +# `plugin.toml`, so a bundle can be found without knowing its path. io operates +# no index, hosts nothing, curates nothing and ranks nothing — a marketplace is +# a repository you named, on your disk, and installing out of one still writes +# exactly the `[[plugin]]` entry below. The clone is a `git` invocation; there +# is no HTTP client here and no network path beside io-harness's. # # One directory can contribute six kinds of thing at once — skills, prompt # templates, [[agent]] definitions, [[mcp]] servers, [[hook]] tables and policy -# layers — which is why there is a surface for it. `/plugin` lists what loaded, -# what each bundle contributed, and every bundle that was declared and DROPPED -# with io-harness's own reason beside it. That loader has no error path: a -# dropped bundle is recorded and otherwise silently absent while every other one -# loads. +# layers — which is why there is a surface for it. `/plugin` lists THREE sets: +# what loaded and what each bundle contributed; what was declared `enabled = +# false`, under its own mark, with what switching it back on would bring; and +# every bundle that was declared and DROPPED with io-harness's own reason beside +# it. That loader has no error path: a dropped bundle is recorded and otherwise +# silently absent while every other one loads. +# +# `enabled` is io-harness 0.70.0's, and it defaults to true, so an entry written +# before it existed means what it always did. WRITING IT COSTS SOMETHING: an +# io-cli built against io-harness 0.69.0 does not know the key and refuses the +# WHOLE FILE rather than ignoring it. Remove the `enabled` keys before +# downgrading. io says this at the moment it writes one. # # WHICH FILE DECLARES A BUNDLE DECIDES WHAT IT MAY CONTRIBUTE. A bundle named in # the project-scoped io.toml — the file a `git clone` delivers — may contribute From ca72e57c687086b840ebca3784ae6cf8f38c880f Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 20:32:48 +0530 Subject: [PATCH 11/12] fix: the adversarial review's findings, and the one the live suite caught MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelfth consecutive release in which this review found real defects behind a fully green suite. Two reviewers, split by area, with the driver given its own pass because nothing under `tests/` can link it. **Two HIGH, and both are the same shape: a state exists that a surface cannot show.** `[[mcp]] enabled = false` is honoured by io-harness 0.70.0 before a server is spawned, dialled or even checked against the policy — and io-cli read none of it. `/mcp` drew "not reached this session", which is true of a switched-off server and reads as one that has not been called yet; both headless verbs printed the same columns as for a live server. This release fixed exactly that defect for `[[plugin]]` and left the sibling surface blind in the same commit that took the pin, while `src/servers.rs` carried a doc explaining why the key could not exist — three clauses io-harness 0.70.0 had already falsified. Writing the key stays the next release's verb; the test asserting that refusal now gives the real reason for it. Two bundles sharing a name inside one marketplace made that bundle permanently uninstallable. The refusal counted hits rather than marketplaces, so it announced "2 marketplaces" for one and printed the same qualified spelling twice — and pasting either returned the identical refusal. Each is now offered by its own directory, and `marketplace::offer` is the single speller, because the guided browser was building the same string by hand and producing the same dead end. **And the live suite caught what no offline gate could.** io-harness 0.70.0 closed io-cli's own #214: `Effect::Ask` on `Act::Exec` raises an approval instead of refusing. So git is no longer refused under the posture the wizard recommends, and `approval::refuses_git` — which answered `effect != Allow` and had zero production callers — was both wrong and unused. Deleted rather than repaired: the question is now answered by observation, in `App::note_git`, which fires on a refusal the harness actually emitted. `/commit allow` survives for `read only`, where the deny is real and there is nothing to answer. The rest: manifest text from a stranger is filtered of control characters and bounded before it is drawn, as `git`'s output already was; hook commands are no longer elided on the one door where consent happens; six of this release's own tests addressed bundles by index or count over `Config::discover`, which layers the developer's real user configuration, and now address them by id; the plural `/plugins ` reaches its parse; the path escaping has an inverse; and F10's `format!` ban is labelled the backstop it is, beside an assertion that actually holds the property. --- CHANGELOG.md | 36 ++++ README.md | 48 +++--- src/app.rs | 28 +++- src/approval.rs | 65 +++++--- src/commands.rs | 9 + src/main.rs | 44 ++++- src/manage.rs | 25 ++- src/marketplace.rs | 349 +++++++++++++++++++++++++++++++++++---- src/pluginview.rs | 100 ++++++++++- src/servers.rs | 52 +++++- tests/commands.rs | 70 ++++++++ tests/dependencies.rs | 101 ++++++++--- tests/live.rs | 79 ++++++--- tests/marketplace.rs | 290 ++++++++++++++++++++++++++++++++ tests/plugins.rs | 180 ++++++++++++++++---- tests/policy.rs | 36 ++-- tests/servers.rs | 21 ++- tests/servers_enabled.rs | 149 +++++++++++++++++ 18 files changed, 1495 insertions(+), 187 deletions(-) create mode 100644 tests/servers_enabled.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b5ed29b..080aeeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,12 +50,48 @@ all, and a configuration whose bundles were all switched off was reported as declaring none. It is listed under its own mark, with what switching it back on would bring. +**Git is asked about rather than refused, if you run the recommended posture.** +Not io-cli's fix — io-harness 0.70.0 closed the issue io-cli 0.25.0 filed against +it. Through 0.69.0 the git spawn accepted only an outright allow, so `ask before +writes` behaved exactly as `deny` did and all seven git tools were refused with +nobody consulted. You now get the question. `/commit allow` and the refusal +explanation are still there and still needed, but only for `read only`, where +`exec` is a deny and there is nothing to answer — and they are offered only where +that deny came from the posture's own default rather than from a rule, because a +rule cannot be widened by a later layer. **The live gate that asserted the old +behaviour is what caught this**, on the first real run after the pin. + **A run that failed its verification exits 6 again.** io-harness 0.70.0 closed this project's own issue #212 and gave that run its own outcome, which — because the enum is `#[non_exhaustive]` — quietly moved it out of the set `io exec` maps to a ceiling and out of the set the gate retry acts on. Both are named again, so a failing gate is retried and reported as what it is. +### Fixed + +- **`/plugins ` and `/servers ` reach the parse that serves them.** + The plural was accepted by the router and refused one module later, so + `/plugins install x` came back "`plugins` is not a surface io manages" while + bare `/plugins` opened the panel. Folded in `manage::parse`, which is the one + door both the slash form and `io plugins …` go through. +- **A bundle whose path holds a `"` or a `\` can be read back.** The writer + escaped both and the two readers decoded neither, so such a bundle could be + declared and then not located again — which meant the disclosure read a + different directory's manifest, and the entry could not be removed from the + surface that declared it. +- **`/mcp`, `io mcp list` and `io mcp get` say when a server is switched off.** + io-harness 0.70.0 honours `enabled` on an `[[mcp]]` entry before anything is + spawned or dialled; io-cli was reading none of it, so a server that could never + start was listed exactly like a live one and every turn quietly ran without its + tools. Writing the key is still not offered — that verb is next. +- Text out of a marketplace manifest is filtered of control characters and + bounded before it is drawn, the way output from `git` already was. A + description holding a raw newline could otherwise forge lines in `/plugin + search` — the surface used to decide whose code to install. +- Where two bundles inside one marketplace share a name, each is offered by its + own directory. The refusal used to print one spelling twice and neither + resolved, so that bundle could not be installed by name at all. + ### Dependencies - io-harness 0.69 → **0.70.0**. `McpServer` gains a required public `enabled` diff --git a/README.md b/README.md index 2233044..9d2e392 100644 --- a/README.md +++ b/README.md @@ -1525,26 +1525,34 @@ default io-harness will use. You are told before the turn is spent, because the author of a commit is the one thing about it that cannot be corrected afterwards without rewriting history. -### The refusal this repairs - -**Before 0.25.0 all seven tools were refused before they ran, for most operators, -and nobody was ever asked.** io-harness's git spawn checks the `exec` policy -itself and accepts only an outright allow. Every other gated act turns an *ask* -into a question on your screen and waits for it; this one returns a refusal -instead, so `ask` behaves exactly as `deny` does. `ask before writes` — the -posture the wizard recommends, and the one this README recommends with it — sets -`exec` to ask. That is the whole of the defect, and it is filed upstream as -io-harness#214. - -What io-cli does about it is name the refusal and offer one rule: `exec` allowed -for `git`, one binary, for this session. `/commit` asks that question *before* it -spends the turn, because a commit the policy was always going to refuse still -costs a real completion to discover. The rule goes through the same remembered -layer as anything else you allow for a session, so it is exactly as strong as -those and no stronger: it widens an asking default and cannot take back a deny -from a layer beneath it. One binary name is also the narrowest grant that works — -an `exec` pattern has no notion of a subcommand, so `git` says *this program may -be spawned* and nothing about any other. +### The refusal this repairs — and the half of it that is now fixed upstream + +**Through io-harness 0.69.0, all seven tools were refused before they ran, for +most operators, and nobody was ever asked.** io-harness's git spawn checked the +`exec` policy itself and accepted only an outright allow. Every other gated act +turned an *ask* into a question on your screen and waited for it; this one +returned a refusal instead, so `ask` behaved exactly as `deny` did — and `ask +before writes`, the posture the wizard recommends, sets `exec` to ask. io-cli +0.25.0 found that and filed it as io-harness#214. + +**io-harness 0.70.0 closed it, and 0.29.0 pins 0.70.0.** An asking posture now +raises an ordinary approval: you get the question, and git runs if you say so. +If you run the recommended posture, none of the rest of this section applies to +you any more. + +What is left is `read only`, where `exec` is a **deny** and there is still no +question for you to answer. There io-cli names the refusal and offers one rule: +`exec` allowed for `git`, one binary, for this session. `/commit` asks that +*before* it spends the turn, because a commit the policy was always going to +refuse still costs a real completion to discover. The rule goes through the same +remembered layer as anything else you allow for a session, so it is exactly as +strong as those and no stronger — and it is offered **only** where the deny came +from the posture's own default rather than from a rule somebody wrote, because a +later layer can add capability but can never take back a denial. Offering it +against a deny rule would be advice that can never be taken. One binary name is +also the narrowest grant that works: an `exec` pattern has no notion of a +subcommand, so `git` says *this program may be spawned* and nothing about any +other. **Under a posture that denies rather than asks, no rule is offered.** A rule is matched before a default, so the same allowance would work under `read only` too, diff --git a/src/app.rs b/src/app.rs index 7a2217d..df3637a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -714,16 +714,26 @@ impl App { /// Allow `git` for the rest of this session. /// - /// **The only door to this rule, and it exists because the ordinary one is - /// shut.** Every other entry in `remembered` arrives through + /// **The only door to this rule, and the reason it exists narrowed under + /// io-harness 0.70.0.** Every other entry in `remembered` arrives through /// [`App::answer_approval`]: the policy said `Ask`, a question reached the - /// operator, and they answered it with *this session*. The harness's git - /// tools never take that path. `Git::run` refuses anything short of - /// `Effect::Allow` before an approver is consulted at all — see - /// [`crate::approval::refuses_git`] — so under the posture the wizard - /// recommends the seven git tools are refused without anyone ever being - /// asked, and there is no question for an answer to attach to. This method is - /// the answer with no question in front of it. + /// operator, and they answered it with *this session*. + /// + /// Through 0.69.0 the harness's git tools never took that path — `Git::run` + /// refused anything short of `Effect::Allow` before an approver was consulted + /// at all, so under the posture the wizard recommends the seven git tools were + /// refused without anyone being asked. io-cli 0.25.0 found that and reported it + /// as io-harness#214, and **0.70.0 closed it**: an asking posture now raises an + /// ordinary approval, so that operator gets a question and this method is not + /// what answers it. + /// + /// What is left is the posture whose `exec` is a **deny** — `read only` — where + /// there is still no question for an answer to attach to. This method remains + /// the answer with no question in front of it, for the case where the deny came + /// from a tier default rather than from a rule; `crate::commit::asked` reads + /// `Verdict::rule` to tell those apart, because under a deny *rule* the + /// allowance can never win and offering it would print advice that can never be + /// taken. /// /// Idempotent, and by value rather than by count: the rule is a fact about /// the session and not a tally of how many times it was asked for. Pressing diff --git a/src/approval.rs b/src/approval.rs index 0dfae8f..f88d789 100644 --- a/src/approval.rs +++ b/src/approval.rs @@ -743,31 +743,52 @@ pub fn session_policy( /// name is the whole vocabulary a rule about git has. pub const GIT: &str = "git"; -/// Will this policy refuse a git spawn? -/// -/// `Ask` counts as a refusal here, and that is the entire point. Every other -/// gated act in the harness turns `Effect::Ask` into a question for the -/// [`Approver`]; git's spawn does not. `Git::run`, at `src/tools/git.rs:549-561` -/// of io-harness 0.69.0, checks `self.policy.check(Act::Exec, &self.program)` and -/// returns `Error::Refused` on **anything that is not `Allow`**, before any -/// approver exists to be consulted. So the question a caller needs answered is -/// not *does this ask* but *does this fall short of allow*, which is what this -/// answers. -pub fn refuses_git(policy: &io_harness::Policy) -> bool { - policy.check(Act::Exec, GIT).effect != Effect::Allow -} +// **`refuses_git` was here, and io-harness 0.70.0 both fixed the defect it +// described and removed the reason it existed.** +// +// It answered `check(Act::Exec, GIT).effect != Effect::Allow`, and its doc +// explained that `Ask` had to count as a refusal because `Git::run` returned +// `Error::Refused` on anything short of `Allow` before any approver was +// consulted — the behaviour io-cli 0.25.0 found and reported as io-harness#214. +// +// 0.70.0 closed that issue at all four sites carrying the comparison: `Ask` on +// `Act::Exec` now raises an approval like every other gated act. So the +// inequality this function was built on stopped describing anything — under the +// posture the wizard recommends, git is *asked about* and then runs, and the +// live arm that asserted a refusal came back with `refusals []`. +// +// It is deleted rather than corrected because it had **no production caller** — +// only tests — and because the question it answered is now answered better by +// observation than by prediction. [`crate::app::App::note_git`] fires on an +// `EventKind::Refused` the harness actually emitted, so it cannot explain a +// refusal that did not happen; and `crate::commit::asked` reads the verdict's +// own `rule` field to decide whether the allowance can help. A predicate that +// guessed at the answer in front of them was a sixth instance of the shape this +// product has shipped five times: public, tested, and called by nothing. +// +// What the tests assert now is `check(Act::Exec, GIT).effect` itself, which is +// the fact, and [`git_allowance`] below is unchanged: it is still what lifts a +// `Deny` that came from a tier default rather than from a rule. /// The rule that lets the git tools run at all. /// -/// [`crate::settings::Posture::AskWrites`] sets `exec: Effect::Ask`, and it is the -/// posture the wizard recommends — so on the reading in [`refuses_git`] the seven -/// git tools are refused, for most operators, without anyone ever being asked. The -/// repair is a rule, not a special case in the spawn path: io-cli offers this -/// through the same `remembered` layer [`effective_policy`] already builds, so a -/// git allowance is exactly as strong as any other thing the operator allowed for -/// the session — it widens an *asking* default and still cannot re-allow a deny -/// from a layer beneath it, because layers add capability and never take a denial -/// back. +/// **The posture this was built for stopped needing it, and the one that still +/// does is the other one.** [`crate::settings::Posture::AskWrites`] sets +/// `exec: Effect::Ask`, and through io-harness 0.69.0 that was a hard refusal in +/// the git spawn — the seven git tools refused for most operators without anyone +/// ever being asked, which io-cli 0.25.0 reported as io-harness#214. 0.70.0 fixed +/// it: an asking posture now raises an approval and git runs when the operator +/// says so, with no rule needed. +/// +/// What still reaches here is a posture whose `exec` is `Deny` — `read only` — and +/// a deny is only liftable when it came from a **tier default** rather than from a +/// rule, which is the discriminator `crate::commit::asked` reads off +/// `Verdict::rule`. The repair is still a rule and not a special case in the spawn +/// path: io-cli offers this through the same `remembered` layer +/// [`effective_policy`] already builds, so a git allowance is exactly as strong as +/// any other thing the operator allowed for the session — and it still cannot +/// re-allow a deny from a layer beneath it, because layers add capability and +/// never take a denial back. /// /// The pattern is one binary name. `Act::Exec` matching (see [`GIT`]) has no /// notion of a subcommand, so `"git"` is the narrowest expressible grant that diff --git a/src/commands.rs b/src/commands.rs index ed30e97..d72baee 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1930,6 +1930,11 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { // media for one reading, not two decisions: the write verbs, where a // disagreement would land in an operator's file, all go through the one // parse. + // **`servers` is `mcp` there too.** The line travels verbatim and + // `manage::parse` folds the plural onto the singular itself — see the fold + // at the top of it — because `io servers add …` from a shell never passes + // through here. Routing a spelling the parse then refuses is the shape + // this arm shipped with until 0.29.0. "mcp" | "servers" if matches!( input.split_whitespace().nth(1), @@ -1980,6 +1985,10 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { // `install` is `add`'s other spelling and `search` is a read across the // marketplaces; both are `manage::parse`'s words, so both route here rather // than opening the panel over a line that asked for something else. + // **And `plugins` is `plugin` on the far side as well**: the fold lives at + // the top of `manage::parse`, where `io plugins install x` from a shell + // reaches it too. Admitting a spelling here that the parse refuses teaches + // less than refusing it outright, which is what this arm did until 0.29.0. "plugin" | "plugins" if matches!( input.split_whitespace().nth(1), diff --git a/src/main.rs b/src/main.rs index 26c582e..6ac99cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2238,10 +2238,21 @@ async fn loop_over Result>( // directory would take the path reading, which // exists for a directory the operator wrote // themselves, and switch this one straight on. + // **One speller, and this was the second.** The + // qualified name is `marketplace::offer`'s to + // write: it answers the *shortest unambiguous* + // spelling, which is the marketplace where the + // label is unique in that clone and the bundle's + // own directory where it is not. Built here by + // hand, this line produced `