From ffceba491858ea3c12ac9eca1e7f7b2f98bf8168 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 10:01:39 +0530 Subject: [PATCH 01/15] =?UTF-8?q?chore(develop):=20`Edit::unset`=20?= =?UTF-8?q?=E2=80=94=20the=20one=20new=20write=20primitive,=20deleting=20a?= =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.28.0-T01 --- src/edit.rs | 138 ++++++++++++++++++ tests/edit.rs | 377 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 515 insertions(+) diff --git a/src/edit.rs b/src/edit.rs index 4061a58..8ad7608 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -74,6 +74,8 @@ enum Kind { Section, /// Remove a whole `[[path]]` entry, or a `[path]` section, bytes and all. Remove, + /// Delete one `key = value` line, leaving the section around it standing. + Unset, /// Move one `[[path]]` entry to another position in its array. Move, } @@ -145,6 +147,62 @@ impl Edit { } } + /// Delete the single `key = value` line at `path`. + /// + /// **A different verb from [`Edit::remove`], and keeping the two apart is + /// the whole of the design.** `remove` takes a REGION away — a `[section]` + /// or one `[[array]]` entry, header and body and every unmodelled key + /// inside it — and it finds that region by matching a header, so it has + /// never been able to name a key: asked for `run.max_steps` it looks for a + /// `[run.max_steps]` header, finds none, and refuses. One verb that deleted + /// a key when it found a key and a whole section when it found a section + /// would read as the same call at every call site, and the two outcomes are + /// the difference between clearing one setting and deleting an operator's + /// entire `[run]` block. Callers of this module are one keystroke from a + /// file somebody's runs depend on, so the ambiguity is settled by which + /// constructor was written rather than by what the document happened to + /// hold — and a path that names the wrong kind of thing is an error instead + /// of a larger deletion than anyone asked for. + /// + /// **Unset, not set-to-empty.** What a caller wants here is the ABSENCE of + /// the key: an io-harness setting the file does not carry falls back to its + /// default or to the layer below, and `key = ""` is a value that shadows + /// both. So the line goes and nothing is left in its place. + /// + /// The whole physical line goes, from its first byte to the newline after + /// the value, which takes an inline comment on that line with it — that + /// comment is a note about the key that is leaving. Nothing else moves: the + /// section header survives even when its last key is unset, and every + /// sibling key, blank line and comment around the deleted line is copied + /// through byte for byte, the same preservation property every other edit + /// in this module keeps. + /// + /// **A value spelled across more than one line takes all of its lines.** + /// The span this works from is the value's, and the cut runs to the end of + /// it, so an array written over four lines or a `"""` block leaves nothing + /// behind. Cutting only the first line would strand the rest of the value + /// as a fragment and turn a deletion into a file that no longer parses, + /// which is the one outcome a writer must never reach quietly. + /// + /// `path` is resolved the way [`value_at`] resolves it, through the same + /// `segments` splitter, so a key written `c = 1` inside `[a.b]` is `a.b.c` + /// and a top-level key is its own bare name. An unindexed path names entry + /// 0 of an array of tables, as [`Edit::set`] does: TOML forbids one table + /// from carrying a key twice, so `[[array]]` entries are the only way one + /// path can address two lines in a document, and the index is how a caller + /// picks between them. + /// + /// A key the file does not carry is refused by name rather than passed over + /// in silence, because a caller told nothing after asking for a removal + /// will believe the removal happened. + pub fn unset(path: impl Into) -> Self { + Self { + path: path.into(), + value: String::new(), + kind: Kind::Unset, + } + } + /// Move the `from`-th entry of the `[[path]]` array to position `to`. /// /// **Order is meaning for an array of tables, and for `[[provider]]` it is @@ -209,6 +267,30 @@ pub fn apply(text: &str, edits: &[Edit]) -> Result { } } + // **An unset and another edit on the same path cannot share a batch**, and + // this is the one place that can see both. Every edit here is resolved + // against the document as it was BEFORE the batch, and an unset resolves to + // a whole line rather than a value's span — so a `set` on the same key + // resolves to a range *inside* the range the unset is about to take away. + // Two overlapping splices against stale offsets do not produce a compromise, + // they produce whichever bytes the arithmetic lands on, and on a short + // replacement they run off the end of the string and panic in a function + // whose whole promise is that a refused edit leaves the file alone. There is + // no reading of "delete this key and also write it" worth guessing at, so it + // is named and refused. The scan is quadratic in the edit count and gated on + // there being an unset at all, which keeps a four-hundred-key price fill at + // no cost. + for edit in edits.iter().filter(|e| e.kind == Kind::Unset) { + if edits.iter().filter(|other| other.path == edit.path).count() > 1 { + return Err(format!( + "`{}` is unset and edited in the same batch, so nothing was written — \ + every edit is resolved against the file as it was before the batch, \ + and an unset takes the whole line the other edit was going to land on", + edit.path + )); + } + } + let regions = regions(text)?; let parsed: toml::Value = toml::from_str(text).map_err(|e| { format!("this configuration file does not parse, so nothing was written: {e}") @@ -347,6 +429,62 @@ pub fn apply(text: &str, edits: &[Edit]) -> Result { splices.push((region.start..removal_end(text, ®ion.body), String::new())); continue; } + Kind::Unset => { + // The same resolution `value_at` does, because a verb that + // deletes a key has to reach exactly the key a reader would have + // been shown — a second path resolver would eventually disagree + // with this one, and the disagreement would surface as a deleted + // line nobody named. + let (table_path, key) = split_path(&edit.path)?; + let region = regions + .iter() + .find(|r| r.path == table_path.names && r.index == table_path.index); + let absent = || format!("there is no `{}` to unset in this file", edit.path); + + let region = match region { + Some(region) => region, + None => { + // No such section. Before reporting the key as absent, + // ask the parsed document — a dotted key or a key inside + // an inline table IS in the file, in a shape that has no + // line of its own to take away, and "there is no such + // key" would be a sentence the caller cannot act on. + if resolve(&parsed, &table_path.names, &key).is_some() { + return Err(dotted_refusal(&edit.path)); + } + return Err(absent()); + } + }; + + let body = &text[region.body.clone()]; + let flat: BTreeMap> = toml::from_str(body) + .map_err(|e| format!("the `{}` section does not parse: {e}", edit.path))?; + let span = flat.get(&key).ok_or_else(absent)?.span(); + let absolute = region.body.start + span.start..region.body.start + span.end; + + // Measurement 4 again, and it matters more here than it does to + // a `set`: a dotted key reports the KEY's span, so a cut around + // it would take the line `a.b = 1` away when `a.c` was what the + // caller asked to be rid of. + let found = &text[absolute.clone()]; + if toml::from_str::(&format!("probe = {found}")).is_err() { + return Err(dotted_refusal(&edit.path)); + } + + // The span covers the value alone, and what has to go is the + // line carrying it: back to the start of the line the key is + // written on, forward past the newline that ends the value's + // last line. Running forward from the value's END rather than + // from its start is what makes a multi-line array or a `"""` + // block leave whole, and what stops this from stranding half a + // value in a file that then does not parse. + let start = text[..absolute.start].rfind('\n').map_or(0, |at| at + 1); + let end = text[absolute.end..] + .find('\n') + .map_or(text.len(), |at| absolute.end + at + 1); + splices.push((start..end, String::new())); + continue; + } Kind::Set => {} } diff --git a/tests/edit.rs b/tests/edit.rs index 8dd680a..d56a1e6 100644 --- a/tests/edit.rs +++ b/tests/edit.rs @@ -515,6 +515,383 @@ input = 1.0 ); } +/// The fixture with one line taken out of it, rebuilt line by line. +/// +/// Byte-exact, and that is the only assertion worth making about an unset: a +/// `contains` check passes for a writer that reordered the file, dropped a +/// comment or rewrote the whitespace, which is every failure this module exists +/// to prevent. Every line of the fixture ends in a newline and there is no `\r` +/// in one, so splitting and rejoining reproduces the file exactly. +fn without_line(text: &str, line: &str) -> String { + let mut kept = String::new(); + let mut found = false; + for each in text.lines() { + if each == line { + found = true; + continue; + } + kept.push_str(each); + kept.push('\n'); + } + assert!(found, "the fixture has no line {line:?} to take out"); + kept +} + +/// **Unset deletes one key's line and leaves every other byte where it was.** +/// +/// The destructive failure mode is not deleting the wrong key, it is deleting +/// the right key and taking the section with it — a cut that ran from the key to +/// the next header, or from the section's header to the key, would satisfy any +/// assertion that only asks whether the key is gone. So the whole document is +/// compared, byte for byte, against itself with exactly one line removed. +/// +/// Sabotage: cut from `region.start` rather than from the key's own line, or run +/// forward to `region.body.end` rather than to the newline after the value, and +/// the sibling key, the header or the blank-line rhythm goes with it. +#[test] +fn f1_an_unset_takes_one_line_and_leaves_the_section_standing() { + let after = edit::apply(OPERATORS_FILE, &[Edit::unset("run.max_tokens")]).unwrap(); + + assert_eq!( + after, + without_line(OPERATORS_FILE, "max_tokens = 100000"), + "unsetting one key changed bytes that were not on its line:\n{after}" + ); + + // Named one at a time, so a failure says which kind of loss happened. + assert!( + after.contains("[run]"), + "the enclosing section header went too" + ); + assert!( + after.contains("max_steps = 30 # deliberately low while I am debugging"), + "the sibling key lost its inline comment, or its spacing" + ); + assert!( + after.contains("# My io configuration."), + "the header comment was lost" + ); + assert!(after.contains("[[agent]]"), "a later section moved"); + + // And the absence is a real absence: the key falls back to its default + // rather than being shadowed by an empty value left in its place. + assert_eq!(edit::value_at(&after, "run.max_tokens"), None); + assert_eq!( + edit::value_at(&after, "run.max_steps").as_deref(), + Some("30"), + "the sibling's value moved" + ); +} + +#[test] +fn f1_an_unset_reaches_a_key_in_a_nested_section_by_its_full_path() { + // `[app.io-cli]` is two segments and the key is a third, so this fails for a + // resolver that treats the last dot as the only separator. + let after = edit::apply(OPERATORS_FILE, &[Edit::unset("app.io-cli.theme")]).unwrap(); + + assert_eq!(after, without_line(OPERATORS_FILE, "theme = \"dark\"")); + assert!( + after.contains("[app.io-cli]"), + "the section header went with its last key:\n{after}" + ); + // A section with nothing in it is still a section, and still parses. + let parsed: toml::Value = toml::from_str(&after).expect("the result parses"); + assert!(parsed["app"]["io-cli"].get("theme").is_none()); +} + +#[test] +fn f1_an_unset_reaches_a_top_level_key_by_its_bare_name() { + const TOP: &str = "\ +# what this file is for +name = \"my project\" +version = \"1\" + +[run] +max_steps = 30 +"; + let after = edit::apply(TOP, &[Edit::unset("name")]).unwrap(); + + assert_eq!(after, without_line(TOP, "name = \"my project\"")); + assert!( + after.contains("# what this file is for"), + "the comment above the file's first key was taken with it:\n{after}" + ); + assert!( + after.contains("version = \"1\""), + "a top-level sibling was lost" + ); +} + +/// **A key that is not there is refused by name, not passed over.** +/// +/// A caller that asks to remove a setting and is told nothing believes the +/// setting was removed. So an unset that found nothing says so, in the same +/// shape [`Edit::remove`]'s refusal has, and names the path it was given. +#[test] +fn f1_an_unset_of_a_key_the_file_does_not_have_is_refused_by_name() { + // The section is there and the key is not. + let err = edit::apply(OPERATORS_FILE, &[Edit::unset("run.max_retries")]).unwrap_err(); + assert!( + err.contains("run.max_retries") && err.contains("unset"), + "the refusal does not name the path it refused: {err}" + ); + + // Neither the section nor the key is there, which is a different arm. + let missing = edit::apply(OPERATORS_FILE, &[Edit::unset("memory.max_rows")]).unwrap_err(); + assert!( + missing.contains("memory.max_rows"), + "the refusal does not name the path it refused: {missing}" + ); + + // Nothing partial: a batch with one bad edit in it writes nothing at all. + let batch = edit::apply( + OPERATORS_FILE, + &[ + Edit::unset("run.max_retries"), + Edit::set("run.max_steps", "45"), + ], + ) + .unwrap_err(); + assert!(!batch.is_empty()); +} + +/// **`remove` names a section and `unset` names a key, and neither answers for +/// the other.** +/// +/// `remove` finds a REGION by matching a header, so `run.max_steps` looks for a +/// `[run.max_steps]` header and finds none — and that refusal has to survive, +/// because a `remove` that fell back to deleting a key line when it could not +/// find a section would make the two constructors interchangeable at the call +/// site and the difference between them is one setting against one whole block. +/// The mirror holds too: `unset` given a section path finds no key of that name. +/// +/// Sabotage: give either arm a fallback into the other, and one of these two +/// halves stops erroring. +#[test] +fn f1_remove_will_not_delete_a_key_and_unset_will_not_delete_a_section() { + let err = edit::apply(OPERATORS_FILE, &[Edit::remove("run.max_steps")]).unwrap_err(); + assert!( + err.contains("run.max_steps") && err.contains("remove"), + "`remove` deleted a key line, or refused without naming what it refused: {err}" + ); + + let mirror = edit::apply(OPERATORS_FILE, &[Edit::unset("run")]).unwrap_err(); + assert!( + mirror.contains("run") && mirror.contains("unset"), + "`unset` deleted a whole section, or refused without naming it: {mirror}" + ); + + // And `remove` still does its own job, so the assertion above is about the + // key path rather than about `remove` being broken. + let removed = edit::apply(OPERATORS_FILE, &[Edit::remove("run")]).unwrap(); + assert!(!removed.contains("[run]")); + assert!(!removed.contains("max_steps")); +} + +#[test] +fn f1_an_unset_leaves_the_comments_and_blank_lines_around_it() { + // The comment above a key is an operator's sentence about a decision, and + // the blank line below it is the rhythm they typed. Neither is on the key's + // line, so neither goes. + const DOCUMENTED: &str = "\ +[run] +# raised while the model was being slow +max_steps = 30 + +# tokens: this ceiling is what the budget allows +max_tokens = 100000 + +[instructions] +files = [\"AGENTS.md\"] +"; + let after = edit::apply(DOCUMENTED, &[Edit::unset("run.max_steps")]).unwrap(); + + assert_eq!( + after, + without_line(DOCUMENTED, "max_steps = 30"), + "an unset moved bytes that were not on the key's line:\n{after:?}" + ); + assert!( + after.contains("# raised while the model was being slow"), + "the comment above the removed key went with it" + ); + assert!( + after.contains("# tokens: this ceiling is what the budget allows"), + "the comment below the removed key went with it" + ); + assert!(!after.contains("max_steps"), "the key survived the unset"); + + // The inline comment on the removed line itself DOES go, because it is a + // note about the key that is leaving. + let inline = edit::apply(OPERATORS_FILE, &[Edit::unset("run.max_steps")]).unwrap(); + assert!( + !inline.contains("# deliberately low while I am debugging"), + "the removed key's own inline comment was left behind as an orphan:\n{inline}" + ); +} + +/// **A value spelled across several lines takes every one of them.** +/// +/// The span is the value's, so a cut that ran from the key's line to the first +/// newline would delete `files = [` and leave the array's rows and its closing +/// bracket stranded in the file. That does not parse, which is the good case; +/// the bad case is the `"""` block, where the leftover `[run]` line inside the +/// prose becomes a section header and the rest of the file lands inside it. +/// +/// Sabotage: run the cut forward from the value's START rather than its end, and +/// both halves of this test fail — the first as a refusal, the second as a file +/// that parses into a different configuration. +#[test] +fn f1_an_unset_of_a_multi_line_value_takes_the_whole_value() { + const SPREAD: &str = "\ +[instructions] +files = [ + \"AGENTS.md\", + \"CONTRIBUTING.md\", +] +text = \"\"\" +[run] +this is prose, not a section +\"\"\" +mode = \"append\" +"; + + let array = edit::apply(SPREAD, &[Edit::unset("instructions.files")]).unwrap(); + assert!( + !array.contains("AGENTS.md"), + "the array's first row survived" + ); + assert!( + !array.contains("CONTRIBUTING.md"), + "only the first line of the array was deleted:\n{array}" + ); + assert!( + !array.contains("\n]"), + "the array's closing bracket was stranded:\n{array}" + ); + assert!( + array.contains("mode = \"append\""), + "a sibling key was lost" + ); + assert!( + array.contains("this is prose, not a section"), + "the multi-line string below the array was damaged:\n{array}" + ); + + let block = edit::apply(SPREAD, &[Edit::unset("instructions.text")]).unwrap(); + assert!( + !block.contains("this is prose, not a section"), + "the body of the multi-line string was left behind:\n{block}" + ); + assert!(block.contains("AGENTS.md"), "the array above it was lost"); + assert!( + block.contains("mode = \"append\""), + "a sibling key was lost" + ); + // The tell that nothing was stranded: the `[run]` line inside the prose did + // not become a section header. + let parsed: toml::Value = toml::from_str(&block).expect("the result parses"); + assert!( + parsed.get("run").is_none(), + "a fragment of the deleted string became a section:\n{block}" + ); +} + +/// **An unset in a batch resolves against the same document every other edit +/// does, and takes more bytes than any of them.** +/// +/// [`edit::apply`] walks the headers once and resolves every edit against the +/// document as it was *before* the batch, then splices right to left so an +/// earlier cut cannot invalidate a later offset. An unset is the widest cut in +/// the module — a whole line rather than a value's span — so it is the one that +/// can swallow another edit's landing place: unsetting a section's LAST key puts +/// the range's end exactly at the point where a `set` of a new key into that +/// section wants to insert. +/// +/// Sabotage: sort the splices ascending, or apply them as they were pushed, and +/// the new key lands inside the deleted range or the deletion runs off the end +/// of a string that has already grown. +#[test] +fn f1_an_unset_and_another_edit_share_one_pass_without_disturbing_each_other() { + let after = edit::apply( + OPERATORS_FILE, + &[ + Edit::unset("run.max_tokens"), + Edit::set("app.io-cli.theme", "\"light\""), + ], + ) + .unwrap(); + + assert!(!after.contains("max_tokens"), "the unset did not happen"); + assert!( + after.contains("theme = \"light\""), + "the set did not happen" + ); + assert!( + after.contains("max_steps = 30 # deliberately low while I am debugging"), + "the batch disturbed a line neither edit named:\n{after}" + ); + + // Order in the slice must not decide the result. + let swapped = edit::apply( + OPERATORS_FILE, + &[ + Edit::set("app.io-cli.theme", "\"light\""), + Edit::unset("run.max_tokens"), + ], + ) + .unwrap(); + assert_eq!(after, swapped, "the batch's result depends on edit order"); + + // The touching case: the unset takes the last line of `[run]`, and the new + // key is inserted at the byte where that line ended. + let touching = edit::apply( + OPERATORS_FILE, + &[ + Edit::unset("run.max_tokens"), + Edit::set("run.max_retries", "3"), + ], + ) + .unwrap(); + assert!( + !touching.contains("max_tokens"), + "the insertion re-created the line that was unset:\n{touching}" + ); + assert!( + touching.contains("max_retries = 3"), + "the new key was deleted by the unset beside it:\n{touching}" + ); + let inserted = touching.find("max_retries").unwrap(); + let agent = touching.find("[[agent]]").unwrap(); + assert!( + inserted < agent, + "the new key landed outside `[run]`:\n{touching}" + ); + + // And the one batch that has no reading worth guessing at is refused rather + // than resolved: two edits on one path describe two overlapping splices of + // the same line, computed against offsets that no longer hold once either + // has been applied. + let conflict = edit::apply( + OPERATORS_FILE, + &[ + Edit::unset("run.max_steps"), + Edit::set("run.max_steps", "45"), + ], + ) + .unwrap_err(); + assert!( + conflict.contains("run.max_steps"), + "the refusal does not name the path both edits claimed: {conflict}" + ); + assert!( + edit::apply(OPERATORS_FILE, &[Edit::set("run.max_steps", "45")]) + .unwrap() + .contains("max_steps = 45"), + "the guard refuses a batch that has only one edit on the path" + ); +} + /// **A section's trailing comment run belongs to the section BELOW it.** /// /// A region's `body.end` is the first byte of the NEXT header, so splicing From fb0ba3c0ed98890c25cb7b57b6baa65eae302afd Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 10:01:56 +0530 Subject: [PATCH 02/15] =?UTF-8?q?chore(develop):=20A=20value=20kind=20for?= =?UTF-8?q?=20all=20thirty-seven=20`CATALOGUE`=20keys,=20the=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.28.0-T02 --- src/configure.rs | 291 ++++++++++++++++++++++++++++++++++++++ tests/configure.rs | 340 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 631 insertions(+) diff --git a/src/configure.rs b/src/configure.rs index 6ed1470..d883524 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -199,6 +199,297 @@ pub const CATALOGUE: &[&str] = &[ "app.io-cli.routing.downshift_under.model", ]; +/// How a value for a key is obtained. +/// +/// **The kind says how a value is *obtained*, never what it means.** io-harness +/// owns discovery, layering and validation, and a surface that started deciding +/// what a setting does would be a second configuration parser disagreeing with the +/// first. What this answers is only: can the options be shown, and in what shape. +/// +/// Until 0.28.0 every one of the thirty-seven keys was typed blind — `/config` +/// prefilled the key and left the value to an operator whatever its kind, so +/// setting `policy.defaults.write` meant guessing a value out of a set the pinned +/// dependency has made public. Asking someone to type a value you could have +/// offered, and could have proven, is the defect this exists to remove. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Kind { + /// `true` or `false`. + Flag, + /// A closed set, whose members are the dependency's own. See [`effects`] and + /// [`exec_modes`] for how each set is obtained and what that guarantees. + Choice(Vec), + /// A whole number, chosen from the ladder [`ladder`] builds. + /// + /// `signed` because `app.io-cli.gates.expect_exit` is an `i32` and a process + /// may legitimately be expected to exit on a negative status; every other + /// number key here counts something and cannot go below zero. + Number { signed: bool }, + /// A model name, chosen from `[prices.models]` already in the file. **Never a + /// network call**: a settings screen that reached for the network to draw a + /// list would be spending an operator's money to render a menu. + Model, + /// A path, chosen from the workspace. + File, + /// A list of strings, written through [`crate::edit::array`]. + /// + /// Exactly one key — `app.io-cli.gates.command` is `Option>` + /// (`src/gates.rs:84`) — and it has its own kind rather than being folded into + /// [`Kind::Text`] because a scalar written to that key is a value io-harness + /// cannot read back. The generic "type a value" editor this release replaces + /// would have written exactly that. + List, + /// Text no menu can hold: a substring to look for, a rubric, a URL. Three keys. + Text, + /// Written by machinery, not by a person, and so never offered for typing. + /// + /// One key, `prices.as_of`. It is in the catalogue because a date an operator + /// cannot see is a claim with no expiry they cannot check — but it is a fact to + /// read, and the act beside it is [`REFRESH_PRICES`]. + 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. +#[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() +} + +/// 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. +#[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() +} + +/// 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 +/// 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. +#[must_use] +pub fn exec_mode_label(mode: io_harness::ExecMode) -> String { + match mode { + io_harness::ExecMode::ReadOnly + | io_harness::ExecMode::WorkspaceWrite + | io_harness::ExecMode::FullAccess => mode.as_str().to_string(), + _ => format!( + "{} — this build of io-cli does not know this mode; it is io-harness's and it is in \ + force", + mode.as_str() + ), + } +} + +/// The kind of a catalogue key, or `None` for a key no catalogue entry names. +/// +/// `None` is not a gap to fill in: [`settings`] deliberately lists keys an +/// operator wrote which this catalogue does not know about, and a kind guessed for +/// one of those would be io-cli inventing a schema. Such a key stays readable and +/// is edited as text. +#[must_use] +pub fn kind_of(key: &str) -> Option { + Some(match key { + // The boundary. Four acts, one set of effects. + "policy.defaults.read" + | "policy.defaults.write" + | "policy.defaults.exec" + | "policy.defaults.net" => Kind::Choice(effects()), + "sandbox.mode" => Kind::Choice(exec_modes()), + // io-cli's own closed sets. Unlike the two above these are this crate's to + // define, so the literal is the schema rather than a copy of one. + "app.io-cli.theme" => Kind::Choice(vec!["dark".into(), "light".into()]), + "app.io-cli.diff" => Kind::Choice(vec!["unified".into(), "minimal".into()]), + "app.io-cli.glyphs" => Kind::Choice(vec!["unicode".into(), "ascii".into()]), + "sandbox.allow_network" + | "sandbox.force_floor" + | "app.io-cli.plain" + | "app.io-cli.detached_spawns" + | "app.io-cli.gates.allow_self_review" + | "app.io-cli.conversational" => Kind::Flag, + // The one signed number: a process may be expected to exit negative. + "app.io-cli.gates.expect_exit" => Kind::Number { signed: true }, + "run.max_steps" + | "run.max_tokens" + | "run.max_duration_secs" + | "run.max_retries" + | "run.exec_timeout_secs" + | "memory.max_entries" + | "memory.max_chars" + | "memory.max_entry_chars" + | "app.io-cli.max_parallel_reads" + | "app.io-cli.spawn_background_after_secs" + | "app.io-cli.gates.retries" + | "app.io-cli.routing.escalate_after.failures" + | "app.io-cli.routing.downshift_under.bytes" => Kind::Number { signed: false }, + "app.io-cli.gates.reviewer" + | "app.io-cli.routing.escalate_after.model" + | "app.io-cli.routing.downshift_under.model" => Kind::Model, + "app.io-cli.gates.file" => Kind::File, + "app.io-cli.gates.command" => Kind::List, + "app.io-cli.gates.contains" + | "app.io-cli.gates.rubric" + | "app.io-cli.prices.source_url" => Kind::Text, + "prices.as_of" => Kind::Machine, + _ => return None, + }) +} + +/// 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 +/// 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 +/// picker saying so, and a surface whose whole argument is that a value is chosen +/// rather than typed cannot have a state in which it offers nothing. +/// +/// The ladder is 1, 2, 5 at each magnitude, which is why it needs no per-key step +/// table to go stale and invents no bound the dependency does not expose. A signed +/// key ladders through zero into the negatives. +#[must_use] +pub fn ladder(current: Option, signed: bool) -> Vec { + let mut rungs: Vec = Vec::new(); + let mut magnitude: i64 = 1; + // Ten magnitudes covers 1 to 5,000,000,000 — past `memory.max_chars` and past + // any token ceiling a provider sells — and stops well short of `i64`'s edge, + // so the multiplication below cannot overflow. + for _ in 0..10 { + for step in [1_i64, 2, 5] { + rungs.push(step * magnitude); + } + magnitude = magnitude.saturating_mul(10); + } + if signed { + let mut whole: Vec = rungs.iter().rev().map(|rung| -rung).collect(); + whole.push(0); + whole.extend(rungs); + rungs = whole; + } else { + rungs.insert(0, 0); + } + // The value in force is a rung whether or not it sits on the ladder, because a + // list that silently omits what the file currently says is a list an operator + // cannot find their own setting in. + if let Some(value) = current { + if !rungs.contains(&value) { + rungs.push(value); + } + } + rungs.sort_unstable(); + rungs.dedup(); + // Nearest the anchor first: the rung an operator wants is almost always the one + // either side of where they are, and a list starting at zero buries it. + // + // **Distance along the ladder, never numeric distance**, and the first attempt + // at this got it wrong in a way only a test caught. The rungs are logarithmic, + // so from 200 the arithmetic neighbours are 100, 50, 20 — every rung *below* — + // and 500, the one step up an operator is most likely to want, sorts ninth. A + // ladder ordered by subtraction is a ladder that only goes down. + let anchor = current.unwrap_or(1); + let at = rungs + .iter() + .position(|rung| *rung == anchor) + // A value in force is always pushed above, so this only runs for `None`, + // where the anchor is 1 and always present. Kept total rather than + // unwrapped: a panic on a settings screen is never the right answer. + .unwrap_or(0); + // The list is sorted and deduplicated, so a rung's index *is* its position on + // the ladder — taken here, before the reorder, because a key that read the list + // it is reordering would be reading positions that have already moved. + let mut ordered: Vec<(usize, i64)> = rungs.into_iter().enumerate().collect(); + ordered.sort_by_key(|(position, rung)| (position.abs_diff(at), *rung)); + ordered.into_iter().map(|(_, rung)| rung).collect() +} + +/// 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 +/// 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. +/// +/// **Mirrored here because a menu that offers a value the destination file will +/// refuse is a menu that lies, and the cost is not one key.** `refuse_widening` +/// runs before deserialization, so the refusal takes the *whole file*: an operator +/// who picks `full-access` on a key their project `io.toml` decides does not get a +/// rejected setting, they get a configuration that no longer parses. `write` +/// already re-reads and refuses with io-harness's own sentence — this is what lets +/// the row say so beforehand instead. +/// +/// The pairs are io-harness's and are spelled here because it exposes no reader for +/// them; `tests/configure.rs` round-trips each one through `Config::from_toml`, so a +/// pair the dependency adds or drops is caught by the gate rather than by an +/// operator. +#[must_use] +pub fn widens_project(key: &str, value: &str) -> bool { + matches!( + (key, value.trim().trim_matches('"')), + ("policy.defaults.exec", "allow") + | ("policy.defaults.net", "allow") + | ("sandbox.allow_network", "true") + | ("sandbox.force_floor", "false") + | ("sandbox.mode", "full-access") + ) +} + /// The `/config` row that re-reads the price catalogue. /// /// **A row rather than a key, because it is an act and not a setting.** Every diff --git a/tests/configure.rs b/tests/configure.rs index 16f0b5b..0547d11 100644 --- a/tests/configure.rs +++ b/tests/configure.rs @@ -553,3 +553,343 @@ fn f10_a_profile_that_is_not_there_reports_the_harness_s_own_sentence() { "io-cli re-worded the harness's refusal: {err}" ); } + +// F1 — every key has a kind, and only the named exceptions permit free text. + +/// Every catalogue key resolves to a kind, and the failure names the key. +/// +/// Sabotage: add a key to `CATALOGUE` without a kind. Under it only this test +/// fails, and it fails by naming the key — a count would say a number is wrong and +/// leave someone to find which one. +#[test] +fn f1_every_catalogue_key_has_a_kind() { + let missing: Vec<&str> = configure::CATALOGUE + .iter() + .copied() + .filter(|key| configure::kind_of(key).is_none()) + .collect(); + assert!( + missing.is_empty(), + "these catalogue keys have no value kind: {missing:?}" + ); +} + +/// The keys that stay free text are exactly three, written out. +/// +/// A literal rather than a count, so a key that drifted into free text is named +/// rather than merely changing a number. Free text is the state this release +/// exists to remove, so growing this set has to be a deliberate act. +#[test] +fn f1_only_three_keys_are_authored_text() { + let mut text: Vec<&str> = configure::CATALOGUE + .iter() + .copied() + .filter(|key| configure::kind_of(key) == Some(configure::Kind::Text)) + .collect(); + text.sort_unstable(); + // Sorted on both sides, so this asserts the set and not the ordering of a + // constant it does not own — and it is a written-out literal, so a key that + // drifted into free text is named by the failure. + assert_eq!( + text, + vec![ + "app.io-cli.gates.contains", + "app.io-cli.gates.rubric", + "app.io-cli.prices.source_url", + ] + ); +} + +/// `app.io-cli.gates.command` is a list and not text. +/// +/// Sabotage: classify it as authored text. Under it this test fails, and the +/// surface writes a bare string into a key io-harness reads as `Vec` — +/// a value the harness cannot read back at all. +#[test] +fn f1_the_command_key_is_the_one_list() { + let lists: Vec<&str> = configure::CATALOGUE + .iter() + .copied() + .filter(|key| configure::kind_of(key) == Some(configure::Kind::List)) + .collect(); + assert_eq!(lists, vec!["app.io-cli.gates.command"]); +} + +/// `prices.as_of` is written by machinery and is never offered for typing. +#[test] +fn f1_the_dated_price_table_is_machine_written() { + assert_eq!( + configure::kind_of("prices.as_of"), + Some(configure::Kind::Machine) + ); + let machine: Vec<&str> = configure::CATALOGUE + .iter() + .copied() + .filter(|key| configure::kind_of(key) == Some(configure::Kind::Machine)) + .collect(); + assert_eq!(machine, vec!["prices.as_of"]); +} + +/// A key no catalogue entry names has no kind, and that is deliberate. +/// +/// `settings` lists keys an operator wrote that io-cli does not know about; a kind +/// guessed for one of those would be io-cli inventing a schema. +#[test] +fn f1_an_unknown_key_has_no_kind() { + assert_eq!(configure::kind_of("something.nobody.declared"), None); +} + +/// The one signed number is the only signed number. +#[test] +fn f1_only_the_expected_exit_status_is_signed() { + let signed: Vec<&str> = configure::CATALOGUE + .iter() + .copied() + .filter(|key| configure::kind_of(key) == Some(configure::Kind::Number { signed: true })) + .collect(); + assert_eq!(signed, vec!["app.io-cli.gates.expect_exit"]); +} + +// 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. +/// +/// 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. +/// +/// Sabotage: add a fourth effect to io-cli's list. Under it F2 fails, because the +/// dependency knows three. +#[test] +fn f2_the_effects_are_the_dependency_s_own() { + let effects = configure::effects(); + assert_eq!(effects, vec!["allow", "ask", "deny"]); + for word in &effects { + let text = format!("[policy]\ndefaults = {{ read = \"{word}\" }}\n"); + assert!( + Config::from_toml(&text).is_ok(), + "io-harness refuses the effect io-cli offers: {word}" + ); + } +} + +/// The exec modes are spelled by io-harness, not by io-cli. +/// +/// 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. +#[test] +fn f2_the_exec_modes_are_spelled_by_the_dependency() { + let modes = configure::exec_modes(); + 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` + // (`config.rs:1759-1769`) refuses five (key, value) pairs in a committed file, + // `sandbox.mode = "full-access"` among them, because `io.toml` arrives with a + // `git clone`. Every mode io-cli offers is a mode io-harness accepts *somewhere*, + // and that is what this asserts; where each one is legal is + // `f2_a_widening_value_is_legal_in_one_scope_and_refused_in_another` below. + for mode in &modes { + let s = scopes(&format!("[sandbox]\nmode = \"{mode}\"\n"), "", ""); + assert_eq!( + s.config().sandbox().expect("a sandbox section").mode.as_str(), + mode, + "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, + ] { + assert!( + modes.contains(&mode.as_str().to_string()), + "{} is a variant with no row", + mode.as_str() + ); + } +} + +/// The five values a committed file may not carry are exactly io-harness's five. +/// +/// **The whole file is refused, not the key**, because `refuse_widening` runs +/// before deserialization — so an operator who picks one of these on a key their +/// project `io.toml` decides gets a configuration that no longer parses. A menu +/// that offered it without saying so would be a menu that lies. +/// +/// Round-tripped through the dependency in both directions, so a pair io-harness +/// adds or drops is caught here rather than by an operator: every value io-cli +/// calls widening must actually be refused, and the narrowing value of each of the +/// same keys must actually be accepted. +/// +/// Sabotage: drop `sandbox.mode`/`full-access` from `widens_project`. Under it this +/// test fails, and the surface goes back to offering the one value that breaks the +/// file it is about to be written into. +#[test] +fn f2_a_widening_value_is_legal_in_one_scope_and_refused_in_another() { + let widening = [ + ("policy.defaults.exec", "allow", "[policy]\ndefaults = { exec = \"allow\" }\n"), + ("policy.defaults.net", "allow", "[policy]\ndefaults = { net = \"allow\" }\n"), + ("sandbox.allow_network", "true", "[sandbox]\nallow_network = true\n"), + ("sandbox.force_floor", "false", "[sandbox]\nforce_floor = false\n"), + ("sandbox.mode", "full-access", "[sandbox]\nmode = \"full-access\"\n"), + ]; + for (key, value, project) in widening { + assert!( + configure::widens_project(key, value), + "{key} = {value} widens and io-cli does not say so" + ); + // The dependency's own answer, at the scope `from_toml` parses as. + let refusal = Config::from_toml(project) + .expect_err("io-harness must refuse this in a committed file") + .to_string(); + assert!( + refusal.contains("widens"), + "io-harness refused {key} for another reason: {refusal}" + ); + // And the same text is accepted where it is the operator's own file. + let s = scopes(project, "", ""); + let _ = s.config(); + } + // The narrowing values of the same keys stay legal in a committed file, which + // is what the scope is for — and are not marked. + for (key, value, project) in [ + ("policy.defaults.exec", "deny", "[policy]\ndefaults = { exec = \"deny\" }\n"), + ("sandbox.mode", "read-only", "[sandbox]\nmode = \"read-only\"\n"), + ("sandbox.allow_network", "false", "[sandbox]\nallow_network = false\n"), + ] { + assert!( + !configure::widens_project(key, value), + "{key} = {value} narrows and must not be marked" + ); + assert!( + Config::from_toml(project).is_ok(), + "io-harness refuses a narrowing value in a committed file: {key}" + ); + } +} + +/// A mode this build does not know is reported, not dropped. +/// +/// Sabotage: make the wildcard omit the row. Under it F2 fails — and it fails on +/// the only failure mode a `#[non_exhaustive]` enum leaves available, a menu +/// 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, + ] { + assert_eq!(configure::exec_mode_label(mode), mode.as_str()); + } +} + +// F4 — the number ladder is one-two-five, anchored on the value in force. + +/// The rungs are 1, 2, 5 at each magnitude and nothing else. +#[test] +fn f4_the_ladder_is_one_two_five() { + let rungs = configure::ladder(Some(8), false); + for rung in &rungs { + if *rung == 0 || *rung == 8 { + continue; + } + let mut value = rung.abs(); + while value % 10 == 0 { + value /= 10; + } + assert!( + matches!(value, 1 | 2 | 5), + "{rung} is not a one-two-five rung" + ); + } +} + +/// Stepping up lands on the next rung and stepping down reverses it exactly. +#[test] +fn f4_stepping_up_and_down_are_inverses() { + let rungs = { + let mut sorted = configure::ladder(Some(200), false); + sorted.sort_unstable(); + sorted + }; + let at = rungs + .iter() + .position(|rung| *rung == 200) + .expect("the value in force is a rung"); + let up = rungs[at + 1]; + let down = rungs[at - 1]; + assert_eq!(up, 500); + assert_eq!(down, 100); + // And back again. + let mut around = configure::ladder(Some(up), false); + around.sort_unstable(); + let back = around + .iter() + .position(|rung| *rung == up) + .expect("the new value is a rung"); + assert_eq!(around[back - 1], 200); +} + +/// The value in force is always on the ladder, even when it is not a rung. +/// +/// A list that silently omits what the file currently says is a list an operator +/// cannot find their own setting in. +#[test] +fn f4_an_odd_value_in_force_is_still_offered() { + assert!(configure::ladder(Some(12_345), false).contains(&12_345)); +} + +/// The nearest rungs come first, because that is the change an operator wants. +#[test] +fn f4_the_ladder_is_ordered_from_the_value_in_force() { + let rungs = configure::ladder(Some(200), false); + assert_eq!(rungs.first(), Some(&200)); + // The two either side lead, in the order their distance puts them. + assert!( + rungs[1..3].contains(&100) && rungs[1..3].contains(&500), + "the neighbours must be the next rows: {:?}", + &rungs[..4] + ); +} + +/// 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. +/// +/// 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". +#[test] +fn f4_an_unset_key_ladders_from_one() { + let rungs = configure::ladder(None, false); + assert_eq!(rungs.first(), Some(&1)); + assert!(!rungs.is_empty()); +} + +/// A signed key ladders through zero into the negatives. +#[test] +fn f4_a_signed_key_reaches_below_zero() { + let rungs = configure::ladder(Some(0), true); + assert!(rungs.contains(&0)); + assert!(rungs.contains(&-1), "a signed ladder reaches below zero"); + assert!(rungs.contains(&1)); + // And an unsigned one never does. + assert!(!configure::ladder(Some(0), false).iter().any(|r| *r < 0)); +} + +/// Zero is reachable on an unsigned key, because zero is a legal ceiling. +#[test] +fn f4_zero_is_a_rung() { + assert!(configure::ladder(Some(1), false).contains(&0)); +} From 4fd93afd0befc9fd7b92355a4165eaabd17a3b20 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 10:02:03 +0530 Subject: [PATCH 03/15] =?UTF-8?q?chore(develop):=20The=20MCP=20policy=20pr?= =?UTF-8?q?eflight=20and=20the=20`Act::Net`=20target=20norm=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.28.0-T05 --- src/lib.rs | 1 + src/preflight.rs | 345 ++++++++++++++++++++++++++++++++ tests/preflight.rs | 475 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 821 insertions(+) create mode 100644 src/preflight.rs create mode 100644 tests/preflight.rs diff --git a/src/lib.rs b/src/lib.rs index f17c0d8..63538ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,6 +81,7 @@ pub mod picker; pub mod picture; pub mod plan; pub mod pluginview; +pub mod preflight; pub mod prices; pub mod provider; pub mod providers; diff --git a/src/preflight.rs b/src/preflight.rs new file mode 100644 index 0000000..deb27d4 --- /dev/null +++ b/src/preflight.rs @@ -0,0 +1,345 @@ +//! Whether the policy in force will let a configured MCP server start — answered +//! when the operator adds it, not when a run finally needs it. +//! +//! **An MCP server is the one piece of configuration whose failure mode is +//! silence.** `claude mcp add` and `codex mcp add` write a line into a file and +//! hand it back; the entry looks exactly as valid as one that works, and the +//! operator learns otherwise on the next turn, from a run that ends before its +//! first step. The refusal is not a bug in either tool — it is the policy doing +//! its job — but the moment it is reported is a choice, and reporting it at add +//! time costs nothing and reporting it at run time costs a turn. +//! +//! # What actually gates a server +//! +//! Two acts, one per transport, both decided before the server process or socket +//! exists: +//! +//! * **stdio** — `McpSession::connect` calls `authorize_spawn` (io-harness 0.69.0, +//! `src/mcp.rs:320`, defined at `:563-590`) *before* `TokioChildProcess::new` at +//! `:326`. That is a plain [`Act::Exec`] check on the command string as written. +//! * **http** — `NetGuard::check` over the URL (`src/mcp.rs:336-339`), which +//! normalises the URL to `host:port` and asks [`Act::Net`] about it. +//! +//! **A refusal from either is `Error::Refused { act, target, rule, layer }`, not +//! `Error::Mcp`.** The rustdoc on `McpServer` at `src/mcp.rs:170-173` says the run +//! "ends in `Error::Mcp` before the server process exists"; it does not — `Error::Mcp` +//! is what a *spawn failure* or a failed handshake produces, once the policy has +//! already allowed the spawn. This module reports the four fields of the refusal +//! io-harness would actually raise, because those are the four an operator can act +//! on: `rule` names the glob and `layer` names the file it came from. +//! +//! # 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` +//! (`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. +//! +//! # It is a disclosure, not a veto +//! +//! Nothing here refuses anything or returns an error. The operator asked for an +//! entry; the entry gets written and the command exits zero whatever this says. +//! A configuration tool that declined to record what its user typed because a +//! policy it does not own would currently refuse it would be wrong twice over: the +//! policy is editable, the server may be for a session that has not been configured +//! yet, and the refusal it is predicting is one the harness will raise perfectly +//! well on its own. The value added is the *sentence*, not a gate. +//! +//! # The boundary, stated honestly +//! +//! The answer is computed from the layers io-cli can see: the file's own `[policy]` +//! section, the tier defaults of the posture the operator chose, and the rules they +//! have allowed for this session — [`crate::approval::session_policy`] composes +//! exactly those three, and it is the same value the next turn runs under. +//! +//! On the ordinary path that is the *whole* policy `McpSession::connect` is handed: +//! `run.rs:3935` passes the caller's policy through untouched. Two layers exist that +//! an embedder cannot read, and neither one is silently in force here: +//! +//! * `net::provider_layer` (`net.rs:334`, `pub(crate)`) allows exactly the provider's +//! own `host:port`, and is merged into the policy that reaches `connect` only on +//! the path that resumes a run after a network approval (`run.rs:2780`, `:3122`). +//! It widens, and it widens for one host that is not an MCP server's. +//! * `plan_lock` (`run/gate.rs:194`, `pub(super)`) denies `write` and `exec` outright +//! while a plan is unreviewed — but it is merged inside the step loop +//! (`run/step.rs:637`, `run/tree.rs:312`), *after* the servers have connected, so it +//! gates the tools an MCP server offers and never the server's own spawn. +//! +//! So this module can be wrong in one direction — it can say a server starts where a +//! layer it cannot see would have widened something — and the direction that matters, +//! predicting a start that the harness refuses, is closed by construction, because +//! every deny it can see is a deny the harness sees too. `Policy::merge` can only +//! 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::{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 +/// to get right. [`Outcome::Unresolvable`] is a **refusal** — it is what +/// `NetGuard::check` does with a URL that yields no host (`net.rs:275-284`: it +/// returns `Error::Refused` with `rule` and `layer` both `None`, before the policy +/// is consulted at all). It is spelled apart from [`Outcome::Refused`] only so the +/// sentence can say *why*, since there is no rule to name and telling an operator +/// their server was "denied by policy" would send them to edit a file that has +/// nothing to do with it. +/// +/// It is a separate variant rather than a boolean beside `Refused` because a match +/// on this enum is exhaustive: a caller that forgets it does not compile. A caller +/// that reads it as "nothing to check, therefore fine" has written the one bug this +/// module was built to prevent, and `tests/preflight.rs` asserts against it directly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + /// The policy allows the act outright. + Permitted, + /// The policy asks. Whether that starts the server depends on the transport — + /// see [`Preflight::starts`], which is the only place that asymmetry is decided. + Ask, + /// A rule or a tier default denies it. + Refused, + /// The URL yields no host, so there is nothing to check — and an unchecked + /// connection is exactly what the guard refuses. + Unresolvable, +} + +/// One server, one question, and everything needed to say who decided. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Preflight { + /// The server's id, as the operator named it. + pub server: String, + /// The act the harness will check: [`Act::Exec`] for stdio, [`Act::Net`] for http. + pub act: Act, + /// What it will check — the command verbatim for stdio, the normalised + /// `host:port` for http, and the URL as written when it could not be normalised. + pub target: String, + /// What that check says. + pub outcome: Outcome, + /// The glob that decided, `None` when the tier default did. + pub rule: Option, + /// The layer that glob came from, `None` when the tier default decided. + pub layer: Option, +} + +impl Preflight { + /// Will the server actually start? + /// + /// **Not the same question as "was it permitted", because the two transports + /// treat `Ask` in opposite ways, and both of those are upstream's behaviour + /// rather than a reading this crate chose:** + /// + /// * A **stdio** spawn goes through `authorize_spawn`, which records a refusal + /// and returns `Error::Refused` on *anything that is not `Allow`* + /// (`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. + /// * 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 + /// connection is made. + /// + /// So an asking policy silently stops one transport and silently permits the + /// other. Collapsing that into one answer would make the report wrong for + /// whichever transport lost the coin toss, which is why the asymmetry is + /// expressed here once, keyed on the act the harness itself keys on. + pub fn starts(&self) -> bool { + match (self.act, self.outcome) { + (Act::Exec, Outcome::Permitted) => true, + (Act::Net, Outcome::Permitted | Outcome::Ask) => true, + _ => false, + } + } +} + +/// Ask the policy what it will do with this server, without starting it. +/// +/// 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` +/// 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 +/// refuse, which is the preflight failing in the only direction that costs anything: +/// a false refusal is a sentence the operator can disprove in one turn, and a false +/// permission is a turn spent discovering that the tool lied. +/// +/// This never errors and never refuses. See the module docs. +pub fn check(server: &McpServer, policy: &Policy) -> Preflight { + let (act, target) = match &server.transport { + // Verbatim: `authorize_spawn` is handed `command` exactly as the file + // spells it, and an `Act::Exec` pattern is matched against the target and + // its basename — never against a command line — so anything done to this + // string here would be asking about a different program. + McpTransport::Stdio { command, .. } => (Act::Exec, command.clone()), + McpTransport::Http { url, .. } => match target(url) { + Some(host_port) => (Act::Net, host_port), + None => { + return Preflight { + server: server.id.clone(), + act: Act::Net, + // The URL as written, which is what `Error::Refused` carries + // in this case — there is no `host:port` to carry instead. + target: url.clone(), + outcome: Outcome::Unresolvable, + rule: None, + layer: None, + } + } + }, + }; + + let verdict = policy.check(act, &target); + Preflight { + server: server.id.clone(), + act, + target, + outcome: match verdict.effect { + Effect::Allow => Outcome::Permitted, + Effect::Ask => Outcome::Ask, + Effect::Deny => Outcome::Refused, + }, + rule: verdict.rule, + layer: verdict.layer, + } +} + +/// The sentence to show the operator. +/// +/// It names the rule and the layer whenever the verdict carried them, because a +/// refusal an operator cannot locate in their own files is one they cannot act on — +/// the same reason [`crate::commit::asked`] names them. When the tier default +/// decided there is no rule to name, and saying so is the answer: it points at the +/// posture rather than at a file, and those are two different repairs. +pub fn line(p: &Preflight) -> String { + let id = &p.server; + let target = &p.target; + match (p.outcome, p.act) { + (Outcome::Unresolvable, _) => format!( + "`{id}` will not start: `{target}` has no host to check. io-harness refuses a \ + target it cannot resolve rather than dialling it, and only `http`, `https`, \ + `ws` and `wss` resolve to one." + ), + (Outcome::Refused, _) => format!( + "`{id}` will not start: {act} `{target}` is denied by {by}.", + act = word(p.act), + by = decided_by(p), + ), + // The spawn is refused without anyone being asked, so the operator has to + // be told the thing the word "ask" would hide from them. + (Outcome::Ask, Act::Exec) => format!( + "`{id}` will not start: running `{target}` is short of allow under {by}, and a server \ + is spawned before the first step — so it is refused rather than asked about.", + by = decided_by(p), + ), + // And the mirror image: it does start, and nobody is asked either. + (Outcome::Ask, _) => format!( + "`{id}` will start: net `{target}` is set to ask by {by}, but the connection is opened \ + before any approver exists, so it proceeds unasked.", + by = decided_by(p), + ), + (Outcome::Permitted, _) => format!( + "`{id}` will start: {act} `{target}` is allowed by {by}.", + act = word(p.act), + by = decided_by(p), + ), + } +} + +/// `exec` or `net`, spelled the way the policy spells it. +fn word(act: Act) -> &'static str { + match act { + Act::Exec => "exec", + Act::Net => "net", + Act::Read => "read", + Act::Write => "write", + } +} + +/// Who decided: the rule and its layer, or the tier default. +/// +/// The `rule`-without-`layer` arm cannot arise from `Policy::explain`, which sets +/// both or neither — it is written as a fallback rather than as an `unreachable!` +/// because the two are separate `Option`s on a public struct, and a panic in a +/// function whose entire job is to describe something is a poor trade. +fn decided_by(p: &Preflight) -> String { + match (&p.rule, &p.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(), + } +} diff --git a/tests/preflight.rs b/tests/preflight.rs new file mode 100644 index 0000000..56aea63 --- /dev/null +++ b/tests/preflight.rs @@ -0,0 +1,475 @@ +//! The MCP policy preflight: whether a server the operator just added will start. +//! +//! 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 verdict half is asserted through `io_harness::Policy`, never through a rendered +//! string alone.** The policy is built the way a session builds it — +//! `approval::session_policy(base, posture, remembered)`, the same call `tests/policy.rs` +//! makes and the same value the next turn runs under — so what is checked is the merged +//! stack, not a policy invented for the test. The sentence is asserted too, but only +//! after the outcome is: a report that names the right rule while reaching the wrong +//! 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 +//! "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. + +use io_cli::approval; +use io_cli::preflight::{self, Outcome, Preflight}; +use io_cli::settings::Posture; +use io_harness::{Act, Config, Effect, McpServer, Policy, Rule}; + +/// A stdio server named `id` spawning `command`. +fn stdio(id: &str, command: &str) -> McpServer { + McpServer::stdio(id, command) +} + +/// An http server named `id` at `url`. +fn http(id: &str, url: &str) -> McpServer { + McpServer::http(id, url) +} + +/// A rule the operator allowed for the session, as `/… allow` records one. +fn remembered(act: Act, effect: Effect, pattern: &str) -> Rule { + Rule { + act, + effect, + pattern: pattern.to_string(), + } +} + +// --------------------------------------------------------------------------- +// The normaliser. One assertion per line of the function it copies. +// --------------------------------------------------------------------------- + +/// The port is filled from the scheme, and all four schemes that reach a host are +/// spelled out. A copy that knew only `http`/`https` would pass every other test in +/// this file and silently report every websocket server as unresolvable. +#[test] +fn target_fills_the_port_from_the_scheme() { + assert_eq!( + preflight::target("https://mcp.example.com").as_deref(), + Some("mcp.example.com:443"), + ); + assert_eq!( + preflight::target("http://mcp.example.com").as_deref(), + Some("mcp.example.com:80"), + ); + assert_eq!( + preflight::target("wss://mcp.example.com").as_deref(), + Some("mcp.example.com:443"), + ); + assert_eq!( + preflight::target("ws://mcp.example.com").as_deref(), + Some("mcp.example.com:80"), + ); +} + +/// A port the URL states is the port that is checked. Overwriting it with the +/// scheme's default would make `allow_net("host:8080")` fail to cover the server it +/// was written for, and — worse — make `deny_net("host:8080")` fail to refuse it. +#[test] +fn target_keeps_a_port_the_url_states() { + assert_eq!( + preflight::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(), + Some("127.0.0.1:3000"), + ); +} + +/// The authority ends at the first `/`, `?` or `#`. A path, a query or a fragment +/// carried into the target would be compared against host rules that can never match +/// it, and a rule that misses fails open. +#[test] +fn target_stops_at_the_path_the_query_and_the_fragment() { + for url in [ + "https://mcp.example.com/mcp/v1", + "https://mcp.example.com?token=abc", + "https://mcp.example.com#frag", + ] { + assert_eq!( + preflight::target(url).as_deref(), + Some("mcp.example.com:443"), + "the authority ends before the first `/`, `?` or `#`: {url}", + ); + } +} + +/// Credentials are not part of the host. A target of `user:pw@host:443` would match +/// no rule an operator would ever write — and it would put the password into every +/// rendered line and every trace row that quotes the target. +#[test] +fn target_drops_the_userinfo() { + assert_eq!( + preflight::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(), + Some("mcp.example.com:443"), + ); +} + +/// An IPv6 literal keeps its brackets, which is the only thing that makes the +/// trailing `:port` split unambiguous. Strip them and `::1` splits into host `:` and +/// port `1`. +#[test] +fn target_keeps_the_brackets_on_an_ipv6_literal() { + assert_eq!(preflight::target("https://[::1]").as_deref(), Some("[::1]:443")); + assert_eq!( + preflight::target("http://[::1]:8080/mcp").as_deref(), + Some("[::1]:8080"), + ); + assert_eq!( + preflight::target("https://[2001:db8::1]").as_deref(), + Some("[2001:db8::1]:443"), + ); +} + +/// A scheme that never opens a connection has no host to check. `None`, and the +/// caller must turn that into a refusal rather than into silence — which is what +/// [`an_unresolvable_url_is_a_refusal_and_never_a_start`] asserts. +#[test] +fn target_refuses_a_scheme_that_never_dials() { + for url in [ + "file:///etc/passwd", + "ftp://mcp.example.com", + "stdio://server", + "HTTPX://mcp.example.com", + ] { + assert_eq!( + preflight::target(url), + None, + "only http, https, ws and wss resolve to a host: {url}", + ); + } +} + +/// Malformed input is `None` too, and the empty-authority cases are the ones a +/// hand-rolled split gets wrong: `https://` and `https:///mcp` both parse far enough +/// to produce an empty string, and an empty host is not a host. +#[test] +fn target_refuses_input_with_no_authority() { + for url in [ + "mcp.example.com", + "https:/mcp.example.com", + "", + "https://", + "https:///mcp", + "https://?q=1", + "https://:443", + ] { + assert_eq!(preflight::target(url), None, "there is no host in {url:?}"); + } +} + +// --------------------------------------------------------------------------- +// The verdict. +// --------------------------------------------------------------------------- + +/// A `deny_exec` naming the server binary refuses the spawn, and the report carries +/// the rule and the layer *the verdict* gave it — not strings this crate composed. +/// An operator sent to the wrong file is no better off than one told nothing. +#[test] +fn a_denied_binary_is_refused_and_names_the_rule_and_the_layer() { + let base = Policy::default() + .layer("ops-baseline") + .deny_exec("github-mcp-server"); + let policy = approval::session_policy(&base, Some(Posture::Workspace), &[]); + + let p = preflight::check(&stdio("github", "github-mcp-server"), &policy); + + assert_eq!(p.act, Act::Exec, "a stdio server is gated as an exec"); + assert_eq!(p.target, "github-mcp-server", "the command, verbatim"); + assert_eq!(p.outcome, Outcome::Refused); + assert!(!p.starts()); + assert_eq!(p.rule.as_deref(), Some("github-mcp-server")); + assert_eq!(p.layer.as_deref(), Some("ops-baseline")); + + let line = preflight::line(&p); + assert!( + line.contains("github-mcp-server") && line.contains("ops-baseline"), + "the sentence must name the rule and the layer that decided: {line:?}", + ); + assert!( + !line.contains("will start"), + "a refusal must not read as a start: {line:?}", + ); +} + +/// The same, over the network act. The rule names the bare host and the target +/// carries the port, which is the pairing `Policy::explain` handles by trying both +/// forms — a preflight that only ever compared the `host:port` form would report this +/// deny as permitted. +#[test] +fn a_denied_host_is_refused_and_names_the_rule_and_the_layer() { + let base = Policy::default() + .layer("ops-baseline") + .deny_net("mcp.example.com"); + let policy = approval::session_policy(&base, Some(Posture::Workspace), &[]); + + let p = preflight::check(&http("remote", "https://mcp.example.com/mcp"), &policy); + + assert_eq!(p.act, Act::Net, "an http server is gated as a net act"); + assert_eq!(p.target, "mcp.example.com:443", "normalised, with its port"); + assert_eq!(p.outcome, Outcome::Refused); + assert!(!p.starts()); + assert_eq!(p.rule.as_deref(), Some("mcp.example.com")); + assert_eq!(p.layer.as_deref(), Some("ops-baseline")); + assert!( + preflight::line(&p).contains("ops-baseline"), + "the layer is where the operator goes to change it", + ); +} + +/// A stdio server the policy allows by name starts, and says so. +#[test] +fn an_allowed_binary_starts() { + let base = Policy::default() + .layer("app") + .allow_exec("github-mcp-server"); + let policy = approval::session_policy(&base, Some(Posture::ReadOnly), &[]); + + let p = preflight::check(&stdio("github", "github-mcp-server"), &policy); + + assert_eq!(p.outcome, Outcome::Permitted); + assert!(p.starts()); + assert_eq!(p.layer.as_deref(), Some("app")); + assert!( + preflight::line(&p).contains("will start"), + "{:?}", + preflight::line(&p), + ); +} + +/// An http server permitted by a rule the operator allowed **for this session**. +/// +/// This is the merged stack doing its job: every posture denies `net` by default +/// (`settings::Posture::defaults`), so without the remembered layer this server is +/// refused. Asserting it here is what proves the preflight reads the same three +/// layers the turn will — file, posture, session — and not just the file's own. +#[test] +fn a_host_allowed_for_the_session_starts() { + let base = Policy::default(); + let server = http("remote", "https://mcp.example.com/mcp"); + + let without = approval::session_policy(&base, Some(Posture::Workspace), &[]); + let p = preflight::check(&server, &without); + assert_eq!( + p.outcome, + Outcome::Refused, + "every posture denies net by default, so this must not start unaided", + ); + + let allowed = [remembered(Act::Net, Effect::Allow, "mcp.example.com")]; + let with = approval::session_policy(&base, Some(Posture::Workspace), &allowed); + let p = preflight::check(&server, &with); + + assert_eq!(p.outcome, Outcome::Permitted); + assert!(p.starts()); + assert_eq!( + p.layer.as_deref(), + Some("remembered"), + "the session's own layer is the one that decided, and is named as such", + ); +} + +/// **The one that must fail loudly.** A URL with no host is a refusal, because +/// `NetGuard::check` refuses on exactly this before the policy is consulted. The +/// assertions are written as three separate claims so that making `None` mean "fine" +/// cannot pass any of them: the outcome is not `Permitted`, the server does not +/// start, and the sentence does not say it will. +#[test] +fn an_unresolvable_url_is_a_refusal_and_never_a_start() { + // The most permissive policy that exists. Nothing here denies anything, so a + // preflight that consulted the policy at all would answer *permitted* — the + // refusal has to come from the unresolvable target itself. + let policy = approval::session_policy(&Policy::permissive(), None, &[]); + + for url in ["file:///usr/local/bin/server", "not-a-url", "https://"] { + let p = preflight::check(&http("broken", url), &policy); + + assert_eq!( + p.outcome, + Outcome::Unresolvable, + "{url}: an unresolvable target is its own refusal", + ); + assert_ne!( + p.outcome, + Outcome::Permitted, + "{url}: reporting permitted here is the preflight lying about a server \ + io-harness will refuse", + ); + assert!( + !p.starts(), + "{url}: `NetGuard::check` returns `Error::Refused` for this, so it does not start", + ); + assert_eq!(p.target, url, "the URL as written is what the refusal carries"); + assert_eq!(p.rule, None, "no rule decided it — there was nothing to check"); + assert_eq!(p.layer, None); + + let line = preflight::line(&p); + assert!( + !line.contains("will start"), + "{url}: the sentence must not promise a start: {line:?}", + ); + } +} + +/// A file with no `[policy]` section yields `Config::policy() == None`, and that +/// absence is `Policy::default()` — never "nothing denies anything". +/// +/// `Policy::default()` asks before an exec and denies net outright, so the honest +/// answer for a freshly-added server in an unconfigured file is that neither +/// transport starts. A caller that read the `None` as [`Policy::permissive`] would +/// report both as permitted, which is the same fail-open shape as the unresolvable +/// URL arriving through a different door. +#[test] +fn a_file_with_no_policy_section_is_the_default_policy_and_not_permission() { + let config = Config::from_toml("[app.io-cli]\ntheme = \"dark\"\n") + .expect("a file with no policy section parses"); + assert!( + config.policy().is_none(), + "the premise of this test: no section means no policy value", + ); + + let policy = config.policy().unwrap_or_default(); + + let spawned = preflight::check(&stdio("local", "some-mcp-server"), &policy); + assert_eq!( + spawned.outcome, + Outcome::Ask, + "the default exec tier asks, and a spawn is not asked about", + ); + assert!( + !spawned.starts(), + "`authorize_spawn` refuses anything short of allow, so an asking default does \ + not start a server — reporting this one as permitted is the failure", + ); + + let dialled = preflight::check(&http("remote", "https://mcp.example.com"), &policy); + assert_eq!( + dialled.outcome, + Outcome::Refused, + "the default net tier is deny, and no rule named this host", + ); + assert!(!dialled.starts()); + + for p in [&spawned, &dialled] { + assert_eq!( + p.rule, None, + "a tier default decided, so there is no rule to name", + ); + assert!( + preflight::line(p).contains("default"), + "with no rule to name, the sentence has to point at the default instead: {:?}", + preflight::line(p), + ); + } +} + +/// A deny in a project layer beats an allow in the user layer beneath it, and beats +/// the posture's tier default over both. The answer follows the merged stack, which +/// is the only reading that matches `Policy::explain` — it resolves deny-first across +/// every layer, and reaches the tier default only when no rule matched at all. +#[test] +fn a_project_deny_beats_the_user_allow_and_the_tier_default() { + let user = Policy::permissive().layer("user").allow_exec("*"); + let project = Policy::permissive() + .layer("project") + .deny_exec("secrets-mcp-server"); + // `Posture::Workspace` allows exec by default, so the tier default would start + // both of these. Only the merged layers say otherwise. + let policy = approval::session_policy(&user.merge(project), Some(Posture::Workspace), &[]); + assert_eq!( + policy.defaults.exec, + Effect::Allow, + "the premise: the default on its own would permit the denied server", + ); + + let denied = preflight::check(&stdio("secrets", "secrets-mcp-server"), &policy); + assert_eq!(denied.outcome, Outcome::Refused); + assert!(!denied.starts()); + assert_eq!( + denied.layer.as_deref(), + Some("project"), + "the deny came from the project layer and that is what the operator is told", + ); + + let allowed = preflight::check(&stdio("notes", "notes-mcp-server"), &policy); + assert_eq!(allowed.outcome, Outcome::Permitted); + assert_eq!( + allowed.layer.as_deref(), + Some("user"), + "the rule that decided is the user layer's `*`, not the tier default — a \ + verdict attributed to the wrong layer sends the operator to the wrong file", + ); +} + +/// An asking policy stops one transport and permits the other, and both halves are +/// upstream's behaviour rather than this crate's reading. +/// +/// `authorize_spawn` returns `Error::Refused` on anything that is not `Allow` +/// (`mcp.rs:571-589`), so an `ask` exec never reaches an approver. `NetGuard::check` +/// returns `Ask` as a verdict and `McpSession::connect` discards it +/// (`mcp.rs:336-339`), so an `ask` net dials anyway. A preflight that mapped `Ask` to +/// one answer for both would be wrong for whichever transport it did not pick. +#[test] +fn an_asking_policy_stops_a_spawn_and_lets_a_dial_through() { + let base = Policy::default() + .layer("cautious") + .rule(Act::Exec, Effect::Ask, "local-mcp-server") + .rule(Act::Net, Effect::Ask, "mcp.example.com"); + let policy = approval::session_policy(&base, Some(Posture::Workspace), &[]); + + let spawned = preflight::check(&stdio("local", "local-mcp-server"), &policy); + assert_eq!(spawned.outcome, Outcome::Ask); + assert!( + !spawned.starts(), + "a server spawn is refused rather than asked about, before the first step", + ); + assert!( + !preflight::line(&spawned).contains("will start"), + "{:?}", + preflight::line(&spawned), + ); + + let dialled = preflight::check(&http("remote", "https://mcp.example.com"), &policy); + assert_eq!(dialled.outcome, Outcome::Ask); + assert!( + dialled.starts(), + "the connect path discards the ask verdict, so the connection is opened", + ); +} + +/// The report is about the server it was handed. A preflight that answered from the +/// id, or from the first server in some list, would be right often enough in a +/// one-server configuration to ship. +#[test] +fn the_report_is_about_the_server_it_was_given() { + let policy = approval::session_policy(&Policy::permissive(), None, &[]); + let p: Preflight = preflight::check(&stdio("notes", "notes-mcp-server"), &policy); + + assert_eq!(p.server, "notes"); + assert_eq!(p.target, "notes-mcp-server"); + assert!( + preflight::line(&p).contains("notes-mcp-server"), + "the sentence names the thing that was checked: {:?}", + preflight::line(&p), + ); +} From 1924004f38f8a40385aff10b5d7c4302d58a5663 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 10:02:06 +0530 Subject: [PATCH 04/15] =?UTF-8?q?chore(develop):=20`/plugin=20add`,=20taki?= =?UTF-8?q?ng=20a=20directory=20from=20a=20picker=20and=20ref=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.28.0-T07 --- src/main.rs | 148 ++++++++++++++++++++++++++++++----- src/pluginview.rs | 115 ++++++++++++++++++++++++++++ tests/plugins.rs | 191 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 437 insertions(+), 17 deletions(-) diff --git a/src/main.rs b/src/main.rs index d27c151..985742e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1764,7 +1764,97 @@ async fn loop_over Result>( } } } - Pick::Plugins(view) => { + // The add verb, and the one row on this surface that is not + // a bundle. It is checked first because `add_at` is past + // the end of both lists and every branch below indexes into + // one of them. + Pick::Plugins { view: _, add_at } if index == *add_at => { + let found = io_cli::pluginview::candidates(session.root()); + if found.is_empty() { + // **Naming where it looked.** "No bundles found" is + // a sentence an operator cannot act on; the depth + // and the root are what tell them their bundle is + // outside the walk rather than unreadable. + app.record( + Tone::Muted, + format!( + "no directory below {} carries a {}; a bundle kept \ + elsewhere is declared with a `[[plugin]]` entry naming \ + its path", + session.root().display(), + io_cli::pluginview::MANIFEST, + ), + ); + } else { + let mut rows = vec![Row::new("leave it".to_string())]; + for dir in &found { + rows.push(Row::with_detail( + io_cli::pluginview::declared(session.root(), dir) + .display() + .to_string(), + format!( + "declares it in the {} scope, from the next turn", + io_cli::configure::Decided::File { + scope: io_harness::config::Scope::User, + path: Default::default(), + } + .word() + ), + )); + } + descended = Some(( + Picker::new("Add a bundle", rows), + Pick::PluginAdd(found), + )); + } + } + // Row 0 is "leave it", so the candidate's own position is + // one less — bound to its own name rather than to `index`, + // which is how a confirmation acts on the wrong row. + Pick::PluginAdd(found) => { + if let Some(dir) = index.checked_sub(1).and_then(|at| found.get(at)) { + // **Checked here and not only when the rows were + // built.** A candidate can lose its manifest between + // the row being drawn and this keystroke, and the + // entry io-harness would then drop is silent — which + // is the state `pluginview`'s module docs exist to + // end, not to reproduce. + match io_cli::pluginview::refusal(dir) { + Some(refusal) => app.record(Tone::Refused, refusal), + None => { + let written = + io_cli::pluginview::declared(session.root(), dir); + let edit = io_cli::pluginview::add(&written); + // The user scope, because a new entry has no + // file already deciding it — and stated + // rather than assumed, which is the rule + // every write in this release follows. + match io_cli::configure::write( + session.root(), + io_harness::config::Scope::User, + &[edit], + ) { + Ok(()) => app.record( + Tone::Success, + format!( + "{} is declared in the {} scope; what it \ + contributes is in `/plugin`, and it is in \ + force from the next turn", + written.display(), + io_cli::configure::Decided::File { + scope: io_harness::config::Scope::User, + path: Default::default(), + } + .word() + ), + ), + Err(refusal) => app.record(Tone::Refused, refusal), + } + } + } + } + } + Pick::Plugins { view, add_at: _ } => { // **Answered from the reading the rows were drawn // from, and `/skills` does the opposite on purpose.** // There, a row names a file the operator may have @@ -3118,25 +3208,29 @@ async fn loop_over Result>( ); } if view.is_empty() { - // Naming the file rather than the concept: an operator who - // has never declared a bundle needs to know where the - // entry goes, and one who has needs to know io-cli read - // the file they meant. + // **Said before the picker opens, not instead of it.** + // Until 0.28.0 this branch was the whole answer for an + // operator with no bundles, and it named a `[[plugin]]` + // entry they then had to go and write by hand — which is + // the shape this release exists to remove. The sentence + // stays because it is still true and still orienting; what + // changes is that the surface now offers the verb instead + // of describing the file. app.record( Tone::Muted, - "no capability bundles are declared; add one with a \ - `[[plugin]]` entry naming its directory" - .to_string(), + "no capability bundles are declared yet".to_string(), ); - } else { - picker = Some(( - Picker::new( - "Plugins", - io_cli::pluginview::rows(&view, screen.width(), &app.theme.glyphs), - ), - Pick::Plugins(view), - )); } + let mut rows = + io_cli::pluginview::rows(&view, screen.width(), &app.theme.glyphs); + // Taken before the row is pushed, never worked out afterwards. + // See `Pick::Plugins`. + let add_at = rows.len(); + rows.push(Row::with_detail( + "add a bundle".to_string(), + "chooses a directory that carries a `plugin.toml`".to_string(), + )); + picker = Some((Picker::new("Plugins", rows), Pick::Plugins { view, add_at })); } // **Everything is shown before anything is written, and the // default for every item is no.** The plan is built whole here — @@ -6342,7 +6436,27 @@ enum Pick { /// A refused bundle is in here too, and choosing one shows io-harness's /// sentence rather than a detail pane: there is nothing to descend into, /// because the bundle contributed nothing at all. - Plugins(io_cli::pluginview::View), + /// + /// `add_at` is where the add row sits, recorded by the code that built the rows + /// rather than recomputed here — `pluginview::rows` draws the loaded bundles + /// and then the refused ones, so the only number that cannot be wrong is the + /// length of what was already there. This is [`Pick::PluginEntry`]'s + /// `action_at` rule, and it is the rule because every index in this surface + /// addresses a list somewhere else. + Plugins { + view: io_cli::pluginview::View, + add_at: usize, + }, + /// The directories below the discovery root that carry a `plugin.toml`, with + /// "leave it" at row 0. + /// + /// **A list rather than a prefilled composer**, which is the verb's whole + /// argument: a path typed from memory is a path that gets mistyped into an + /// entry io-harness then silently drops. A directory that is not a bundle is + /// still refused by name on the way through, because the typed path is not the + /// only way a wrong one arrives — a candidate can lose its manifest between the + /// row being drawn and this keystroke. + PluginAdd(Vec), /// One bundle's contributions, and the one thing that can be done about it. /// /// `bundle` is the resolved directory, which is the only thing a row on screen diff --git a/src/pluginview.rs b/src/pluginview.rs index ac68509..a2f4df0 100644 --- a/src/pluginview.rs +++ b/src/pluginview.rs @@ -404,6 +404,121 @@ pub fn add(dir: &Path) -> crate::edit::Edit { ) } +/// The manifest every bundle has, and the one file this surface can check before +/// writing anything. +/// +/// **io-harness's own constant, re-exported rather than spelled.** The name a +/// directory is recognised by is the dependency's to state; a literal here would +/// go on matching `plugin.toml` through a release that renamed it, and every check +/// below would then answer confidently about a file io-harness no longer reads. +pub const MANIFEST: &str = io_harness::PLUGIN_FILE; + +/// How deep [`candidates`] looks for a bundle below the discovery root. +/// +/// Three, because a bundle is conventionally vendored one or two directories down +/// — `bundles/rust-review`, `vendor/plugins/rust-review` — and a walk that keeps +/// going is a walk that reads a whole `target/` on a settings screen. An operator +/// whose bundle lives deeper still has the typed path, which is refused by the +/// same check rather than by a shallower one. +const DEPTH: usize = 3; + +/// Why `dir` is not a bundle, or `None` when it is one. +/// +/// **The check before the write, and the reason it exists is that io-harness has +/// no error path for this.** `Config::plugins()` is infallible and a `[[plugin]]` +/// entry naming a directory with no manifest is *dropped* — recorded and otherwise +/// silently absent — so an entry written without this check produces exactly the +/// state the module docs above call a bundle an operator believes is loaded and +/// which is silently absent for a week. The refusal names the directory and the +/// file it looked for, because "not a bundle" tells an operator nothing about what +/// to do next. +/// +/// Deliberately the *only* thing checked. Whether the manifest parses, declares a +/// usable `name`, or may make its contributions in the scope it is being declared +/// in are all io-harness's questions, and it answers them with sentences this +/// surface carries verbatim rather than paraphrasing. Existence is the one part +/// io-harness cannot report before the entry exists. +#[must_use] +pub fn refusal(dir: &Path) -> Option { + if dir.join(MANIFEST).is_file() { + return None; + } + Some(if dir.is_dir() { + format!( + "{} has no {MANIFEST}, so it is not a capability bundle; nothing was written", + dir.display() + ) + } else { + format!("{} is not a directory; nothing was written", dir.display()) + }) +} + +/// The directories below `root` that carry a [`MANIFEST`], nearest first. +/// +/// **A list to choose from rather than a path to remember, which is the whole +/// point of the verb.** The alternative — a composer prefilled with `/plugin add ` +/// — asks an operator to type a path they have to go and look up, and mistypes it +/// into an entry that is then silently dropped. +/// +/// Ordered by depth and then by path so the answer is stable between two calls on +/// one machine, which is what makes the row an operator picked yesterday the row +/// in the same place today. `target`, `node_modules` and every dotted directory +/// are skipped: none of them is somewhere a person puts a bundle, and `target` is +/// the one that would make this walk expensive. +#[must_use] +pub fn candidates(root: &Path) -> Vec { + let mut found = Vec::new(); + let mut frontier = vec![(root.to_path_buf(), 0usize)]; + while let Some((dir, depth)) = frontier.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with('.') || name == "target" || name == "node_modules" { + continue; + } + if path.join(MANIFEST).is_file() { + found.push(path.clone()); + } + if depth + 1 < DEPTH { + frontier.push((path, depth + 1)); + } + } + } + found.sort_by_key(|path| (path.components().count(), path.clone())); + found +} + +/// How a chosen directory is written into the entry. +/// +/// Relative to `root` when it sits below it, absolute otherwise — and that is not +/// a tidying preference, it is the difference between a bundle a repository can +/// share and one only this machine can load. [`add`] writes the path as given and +/// a relative one resolves against the discovery root, so a `bundles/rust-review` +/// in a committed `io.toml` works for everyone who clones it while this machine's +/// `/Users/…/bundles/rust-review` works for nobody else. +#[must_use] +pub fn declared(root: &Path, dir: &Path) -> PathBuf { + dir.strip_prefix(root).map_or_else( + |_| dir.to_path_buf(), + |relative| { + // A directory that *is* the root strips to nothing, and an empty path + // in an entry names no directory at all. + if relative.as_os_str().is_empty() { + dir.to_path_buf() + } else { + relative.to_path_buf() + } + }, + ) +} + /// The edit that removes the `index`-th declared bundle whole. /// /// By index rather than by id, because the id is the *manifest's* and the entry is diff --git a/tests/plugins.rs b/tests/plugins.rs index 3b81e5a..14670a1 100644 --- a/tests/plugins.rs +++ b/tests/plugins.rs @@ -873,3 +873,194 @@ fn declared_at_is_none_where_no_file_declares_a_plugin_array() { None, ); } + +// F11 — `/plugin add` refuses a directory that is not a bundle, before writing. +// +// The whole criterion turns on the word *before*. io-harness has no error path +// here: `Config::plugins()` is infallible and an entry naming a directory with no +// manifest is dropped — recorded and otherwise silently absent — so an add that +// wrote first and discovered afterwards would produce exactly the state +// `src/pluginview.rs:15-24` says this surface exists to end. + +/// A directory with no manifest is named and refused. +/// +/// Sabotage: write the `[[plugin]]` entry and let `io_harness::Plugins` drop it. +/// Under it this test fails, and it fails by producing the silently-absent bundle +/// the module docs open with. +#[test] +fn f11_a_directory_with_no_manifest_is_refused_by_name() { + let (_guard, root) = root(); + let plain = root.join("not-a-bundle"); + std::fs::create_dir_all(&plain).expect("the directory"); + + let refusal = pluginview::refusal(&plain).expect("a directory with no manifest is refused"); + // Named, because "not a bundle" tells an operator nothing about what to do. + assert!( + refusal.contains(&plain.display().to_string()), + "the refusal must name the directory: {refusal}" + ); + assert!( + refusal.contains(pluginview::MANIFEST), + "the refusal must name the file it looked for: {refusal}" + ); + // And it must say nothing was written, because nothing was. + assert!( + refusal.contains("nothing was written"), + "the refusal must say the file is unchanged: {refusal}" + ); +} + +/// A path that is not a directory at all is refused, and differently. +/// +/// A file and an empty directory are two different mistakes and the sentence that +/// fixes one does not fix the other. +#[test] +fn f11_a_path_that_is_not_a_directory_is_refused_as_one() { + let (_guard, root) = root(); + let file = root.join("plugin.toml"); + std::fs::write(&file, "name = \"x\"").expect("the file"); + + let refusal = pluginview::refusal(&file).expect("a file is refused"); + assert!( + refusal.contains("is not a directory"), + "a file must be refused as a file: {refusal}" + ); +} + +/// A directory that is a bundle is accepted. +#[test] +fn f11_a_bundle_directory_is_not_refused() { + let (_guard, root) = root(); + let dir = bundle(&root, "bundles/rust-review", MINIMAL); + + assert_eq!(pluginview::refusal(&dir), None); +} + +/// The candidates are the directories that carry a manifest, and nothing else. +/// +/// Sabotage: return every directory below the root. Under it this test fails on +/// the plain directory, and the surface offers rows that are all refused when +/// chosen — a menu whose entries do not work. +#[test] +fn f11_candidates_are_the_directories_carrying_a_manifest() { + let (_guard, root) = root(); + let review = bundle(&root, "bundles/rust-review", MINIMAL); + std::fs::create_dir_all(root.join("bundles/notes")).expect("a plain directory"); + + let found = pluginview::candidates(&root); + assert!(found.contains(&review), "the bundle must be offered: {found:?}"); + assert!( + !found.contains(&root.join("bundles/notes")), + "a directory with no manifest must not be offered: {found:?}" + ); +} + +/// `target`, `node_modules` and dotted directories are not walked. +/// +/// Not a tidiness preference: `target` is the one that makes this walk expensive, +/// and it is walked on a settings screen where an operator is waiting. +#[test] +fn f11_the_candidate_walk_skips_the_directories_nobody_keeps_a_bundle_in() { + let (_guard, root) = root(); + let kept = bundle(&root, "bundles/rust-review", MINIMAL); + bundle(&root, "target/debug/rust-review", MINIMAL); + bundle(&root, "node_modules/rust-review", MINIMAL); + bundle(&root, ".cache/rust-review", MINIMAL); + + let found = pluginview::candidates(&root); + assert_eq!( + found, + vec![kept], + "only the bundle outside the skipped directories is offered" + ); +} + +/// The walk is bounded, and a bundle below the bound is not offered. +/// +/// Asserted so the bound is a decision rather than an accident: an operator whose +/// bundle sits deeper has the typed path, and it is refused by the same check. +#[test] +fn f11_the_candidate_walk_is_bounded() { + let (_guard, root) = root(); + let near = bundle(&root, "bundles/rust-review", MINIMAL); + bundle(&root, "a/b/c/d/deep", MINIMAL); + + let found = pluginview::candidates(&root); + assert!(found.contains(&near)); + assert!( + !found.contains(&root.join("a/b/c/d/deep")), + "a bundle below the depth bound is not walked to: {found:?}" + ); +} + +/// The candidate order is stable, so the row an operator picked yesterday is the +/// row in the same place today. +#[test] +fn f11_the_candidate_order_is_stable() { + let (_guard, root) = root(); + bundle(&root, "zeta", MINIMAL); + bundle(&root, "alpha", MINIMAL); + bundle(&root, "bundles/rust-review", MINIMAL); + + assert_eq!(pluginview::candidates(&root), pluginview::candidates(&root)); + // Nearest first: depth leads, then the path. + let found = pluginview::candidates(&root); + let depths: Vec = found.iter().map(|p| p.components().count()).collect(); + let mut sorted = depths.clone(); + sorted.sort_unstable(); + assert_eq!(depths, sorted, "shallower bundles come first: {found:?}"); +} + +/// A bundle below the root is written relative, so a committed `io.toml` works for +/// everyone who clones it. +/// +/// Sabotage: always write the absolute path. Under it this test fails, and the +/// entry it writes names a directory that exists on exactly one machine. +#[test] +fn f11_a_bundle_below_the_root_is_declared_relative() { + let (_guard, root) = root(); + let dir = bundle(&root, "bundles/rust-review", MINIMAL); + + assert_eq!( + pluginview::declared(&root, &dir), + Path::new("bundles/rust-review"), + ); +} + +/// A bundle outside the root keeps its absolute path, because a relative one would +/// resolve against the discovery root and name somewhere else entirely. +#[test] +fn f11_a_bundle_outside_the_root_is_declared_absolute() { + let (_elsewhere_guard, elsewhere) = root(); + let (_guard, here) = root(); + let dir = bundle(&elsewhere, "rust-review", MINIMAL); + + assert_eq!(pluginview::declared(&here, &dir), dir); +} + +/// The written entry re-reads as a loaded bundle through io-harness's own reader. +/// +/// The end-to-end half of F11: the check passed, the edit applied, and the thing +/// the operator was promised is what `Config::plugins()` reports. +#[test] +fn f11_an_added_bundle_loads_through_the_harness() { + let (_guard, root) = root(); + let dir = bundle(&root, "bundles/rust-review", MINIMAL); + assert_eq!(pluginview::refusal(&dir), None); + + let written = pluginview::declared(&root, &dir); + let text = io_cli::edit::apply("", &[pluginview::add(&written)]).expect("the edit applies"); + std::fs::write(root.join(PROJECT_FILE), &text).expect("the project file"); + + let config = Config::discover(&root).expect("the written file loads"); + let view = pluginview::view(&config); + assert!( + view.refused.is_empty(), + "the added bundle must not be refused: {:?}", + view.refused + ); + assert_eq!( + view.plugins.iter().map(|p| p.id.as_str()).collect::>(), + vec!["rust-review"], + ); +} From 693215a9a4bbfb25c0f149c272f7d72a08ecd0cf Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 10:15:08 +0530 Subject: [PATCH 05/15] =?UTF-8?q?chore(develop):=20The=20`/config`=20value?= =?UTF-8?q?=20surface=20=E2=80=94=20arrows=20cycling=20in=20place,=20?= =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.28.0-T04 --- src/configure.rs | 183 +++++++++++++++++++++++ src/main.rs | 373 ++++++++++++++++++++++++++++++++++++++++++++++- tests/values.rs | 363 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 918 insertions(+), 1 deletion(-) create mode 100644 tests/values.rs diff --git a/src/configure.rs b/src/configure.rs index d883524..fe95d58 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -458,6 +458,189 @@ pub fn ladder(current: Option, signed: bool) -> Vec { ordered.into_iter().map(|(_, rung)| rung).collect() } +/// The next value along, for a kind that can be cycled where it stands. +/// +/// **Booleans and closed enums only, and a number deliberately not.** A number's +/// ladder cannot be shown on the row it is being changed on, and a held arrow +/// would write the file once per key repeat; it descends into a value picker +/// instead. So this answers `None` for every other kind, and the caller's arrow +/// does nothing rather than doing something invisible. +/// +/// **The keys are horizontal arrows because they are not printable.** +/// `crate::picker::Picker` consumes every printable character as a fuzzy filter +/// (`src/picker.rs:423-430`), so the obvious binding — Space — cannot be used: a +/// two-word query contains one, and binding it would toggle a setting in the +/// middle of typing a search. `Left` and `Right` fall to the picker's `_ => +/// Outcome::Idle` arm and are the only free keys that read as "the value beside +/// this one". +/// +/// `current` is the value in force as the file spells it, or `None` for a key no +/// file names. An unset boolean cycles to `true` first, because the operator +/// reached for the arrow in order to change something. +#[must_use] +pub fn cycled(kind: &Kind, current: Option<&str>, forward: bool) -> Option { + let options: Vec = match kind { + Kind::Flag => vec!["false".to_string(), "true".to_string()], + Kind::Choice(options) => options.clone(), + _ => return None, + }; + let bare = current.map(|value| value.trim().trim_matches('"')); + let at = bare.and_then(|value| options.iter().position(|option| option == value)); + let next = match at { + // Wrapping, because a closed set has no end to fall off: an operator + // arrowing past the last option means the first one. + Some(at) if forward => (at + 1) % options.len(), + Some(at) => (at + options.len() - 1) % options.len(), + // A key no file names, or one holding a value this build does not know. + // Either way there is no position to step from, so the two directions + // enter the list at its two ends — forward at the last option, backward at + // the first. For a boolean that means the first press of `Right` turns it + // on, which is what an operator reaching for the arrow on an unset flag + // meant; for a closed enum it is simply symmetric, and one more press + // reaches everything either way. + None if forward => options.len() - 1, + None => 0, + }; + options.get(next).cloned() +} + +/// A value as TOML spells it for that kind. +/// +/// **The serialized value, never the label**, which is the difference F3's own +/// sabotage turns on: writing `dark` unquoted where a string belongs produces a +/// file io-harness refuses, and a check that quoted back the bytes just written +/// would pass anyway. A boolean and a number are bare; everything else is a basic +/// string; a list goes through [`crate::edit::array`], which is the one renderer +/// that already knows how. +#[must_use] +pub fn spell_value(kind: &Kind, value: &str) -> String { + let bare = value.trim().trim_matches('"'); + match kind { + Kind::Flag | Kind::Number { .. } => bare.to_string(), + Kind::List => { + let words: Vec<&str> = bare.split_whitespace().collect(); + crate::edit::array(&words) + } + _ => format!("\"{bare}\""), + } +} + +/// The shape a typed value must take, and a worked example of one. +/// +/// **What remains typed is only what no menu can hold**, and each of those has to +/// say what it wants before it asks. A composer opened with a bare key and no +/// candidates is the state this release exists to remove; where the value genuinely +/// cannot be offered, the next best thing is a sentence naming the shape and one +/// line showing it. +/// +/// `None` for a kind that is chosen rather than typed — those descend into their +/// values and never reach a composer. +#[must_use] +pub fn shape_of(key: &str, config: &Config) -> Option { + let said = match key { + "app.io-cli.gates.command" => { + "a command line, split on spaces into a list — for example: cargo test --all" + } + "app.io-cli.gates.contains" => { + "a substring to look for in what the command printed — for example: test result: ok" + } + "app.io-cli.gates.rubric" => { + "a sentence a reviewer model judges the turn against — for example: every public \ + item changed in this turn carries a doc comment" + } + "app.io-cli.prices.source_url" => { + "a URL returning a model catalogue — for example: https://openrouter.ai/api/v1/models" + } + "prices.as_of" => { + "written by the price refresh rather than typed; use the last row of `/config` to \ + re-read the catalogue" + } + // A key the catalogue does not name. Its shape is the operator's own + // business — io-cli has no schema for it and inventing one here would be + // this module claiming to know a key it does not. + _ => return None, + }; + let setting = setting(config, key); + Some(match setting.value { + Some(value) => format!("{key} is {value} ({}); {said}", setting.decided.word()), + None => format!("{key} is not set; {said}"), + }) +} + +/// 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. +/// +/// **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. +#[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() +} + +/// Which file a change to `key` should be written into, and whether that was +/// inherited from the file already deciding it. +/// +/// **A write goes where the key already lives.** Asking every time costs more than +/// the change did — the value was chosen in one keystroke — and answering "the +/// user scope" every time is worse than asking: it silently shadows a committed +/// project setting with a personal one, which is the change an operator is least +/// able to see afterwards. A key no file names has nothing to inherit and goes to +/// the operator's own file. +/// +/// The boolean is what lets the confirmation say *which* file and *why*, so the +/// scope is stated either way and never assumed silently. An explicit choice +/// overrides it and moves the key between files. +#[must_use] +pub fn destination(config: &Config, key: &str) -> (Scope, bool) { + match setting(config, key).decided { + Decided::File { scope, .. } => (scope, true), + Decided::Default => (Scope::User, false), + } +} + /// 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` diff --git a/src/main.rs b/src/main.rs index 985742e..08ef101 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1145,6 +1145,44 @@ async fn loop_over Result>( continue; } + // **The one key a picker does not own, and only on one surface.** A + // horizontal arrow over a `/config` row cycles that row's value where it + // stands, which is the whole reason the binding is an arrow: the picker + // consumes every printable character as a fuzzy filter, so Space — the + // obvious key — would toggle a setting in the middle of a two-word query. + // `Left`/`Right` reach the picker's `_ => Outcome::Idle` arm and do + // nothing there, so intercepting them takes no behaviour away from any + // other surface. + // + // Handled before `open.key(key)` rather than inside `Picker`, so the + // picker stays a generic list and the nine other call sites keep the + // keyboard they had. + if let Some((open, Pick::Config(paths))) = picker.as_mut() { + let step = match key.code { + KeyCode::Right => Some(true), + KeyCode::Left => Some(false), + _ => None, + }; + if let Some(forward) = step { + let row = open.selection(); + let chosen = row.and_then(|row| paths.get(row)).cloned(); + if let Some(key_name) = chosen.filter(|name| { + name.as_str() != io_cli::configure::REFRESH_PRICES + }) { + cycle_setting( + session.root(), + &mut config, + &mut app, + &key_name, + forward, + open, + row.unwrap_or(0), + ); + } + paint(screen, &mut app)?; + continue; + } + } // A picker owns the keyboard while it is open, which is what makes it a // modal overlay rather than a suggestion. if let Some((open, kind)) = picker.as_mut() { @@ -2219,9 +2257,120 @@ async fn loop_over Result>( Err(error) => app.record(Tone::Error, error), } } - Some(key) => app.composer.set(&format!("/config {key} ")), + // **The prefill is gone, and that is the release.** + // A key whose values are knowable descends into them; + // a key whose value no menu can hold still goes to the + // composer, but says the shape it wants and shows a + // worked example first. Nothing opens a bare composer + // line any more, which is F12. + Some(key) => { + let key = key.to_string(); + match value_rows(session.root(), &config, &key) { + Some(descent) => descended = Some(descent), + None => { + if let Some(shape) = + io_cli::configure::shape_of(&key, &config) + { + app.record(Tone::Muted, shape); + } + app.composer.set(&format!("/config {key} ")); + } + } + } None => {} }, + // Row 0 is "leave it", the default and the same shape every + // other confirmation on this surface uses. + Pick::ConfigValue { + key, + kind, + values, + scope, + unset_at, + elsewhere_at, + } => { + let root = session.root().to_path_buf(); + if index == *elsewhere_at { + // The scope picker, for an operator moving a key + // between files rather than changing its value. + // The current value travels with it, so the move + // does not also ask them to retype what they had. + let current = io_cli::configure::setting(&config, key) + .value + .unwrap_or_default(); + descended = + Some(write_where(&root, key.clone(), current)); + } else if index == *unset_at { + match io_cli::configure::write( + &root, + *scope, + &[io_cli::edit::Edit::unset(key.clone())], + ) { + Err(refusal) => app.record(Tone::Refused, refusal), + Ok(()) => { + match io_cli::configure::reload(&root) { + Ok((fresh, _)) => config = fresh, + Err(error) => app.record(Tone::Error, error), + } + // **The key is gone, so the origin column + // says `default` and names no path.** + // Writing the default's text instead would + // attribute a crate default to a file the + // operator never wrote it in, which is the + // lie `configure`'s own module docs open + // with. + app.record( + Tone::Success, + format!( + "{key} is no longer set in any file; io-harness's \ + own default is in force from the next turn" + ), + ); + } + } + } else if let Some(value) = + index.checked_sub(1).and_then(|at| values.get(at)) + { + if *scope == io_harness::config::Scope::Project + && io_cli::configure::widens_project(key, value) + { + app.record( + Tone::Refused, + format!( + "{key} is decided by the project file, and a committed \ + file may not set it to {value} — io-harness refuses \ + the whole file for it, not just the key" + ), + ); + } else { + let edit = io_cli::edit::Edit::set( + key.clone(), + io_cli::configure::spell_value(kind, value), + ); + match io_cli::configure::write(&root, *scope, &[edit]) { + Err(refusal) => app.record(Tone::Refused, refusal), + Ok(()) => { + match io_cli::configure::reload(&root) { + Ok((fresh, _)) => config = fresh, + Err(error) => app.record(Tone::Error, error), + } + app.record( + Tone::Success, + format!( + "{key} is {value}, in the {} scope, from the \ + next turn", + io_cli::configure::Decided::File { + scope: *scope, + path: Default::default(), + } + .word() + ), + ); + } + } + } + } + } // The gates surface. Every row but the first names a key // and goes to the composer, which is `/config`'s own // shape and reaches `/config`'s own write — the value of @@ -6457,6 +6606,27 @@ enum Pick { /// only way a wrong one arrives — a candidate can lose its manifest between the /// row being drawn and this keystroke. PluginAdd(Vec), + /// One setting's values, as rows, with "leave it" at row 0. + /// + /// **The descent that ends free text.** Until 0.28.0 choosing a `/config` row + /// prefilled the composer with the key and left the value to be guessed out of + /// a set the pinned dependency has made public. `values` is that set, obtained + /// by kind; `unset_at` is the row that removes the key rather than writing a + /// default's text into a file the operator never wrote it in; `elsewhere_at` + /// opens the scope picker, because a write inherits the deciding file and an + /// operator moving a key between files still needs a way to say so. + /// + /// `scope` is where the write lands, resolved when the rows were built rather + /// than recomputed on the keystroke — the same rule every other confirmation on + /// this surface follows, so the file named in the title is the file written to. + ConfigValue { + key: String, + kind: io_cli::configure::Kind, + values: Vec, + scope: io_harness::config::Scope, + unset_at: usize, + elsewhere_at: usize, + }, /// One bundle's contributions, and the one thing that can be done about it. /// /// `bundle` is the resolved directory, which is the only thing a row on screen @@ -6540,6 +6710,207 @@ enum Pick { /// /// Every scope is offered whether or not its file exists yet, because writing a /// key into a scope for the first time is how this is used. +/// The values one setting can take, as a picker, or `None` when it has to be typed. +/// +/// **The kind decides, and every option comes from somewhere that cannot go +/// stale**: the effects and exec modes from io-harness's own types, a number from +/// the ladder built around the value in force, a model from `[prices.models]` +/// already in the file, a file from the workspace. Nothing here is a per-key table +/// and nothing here reaches the network. +/// +/// `None` means the value is genuinely unofferable — a substring, a rubric, a URL, +/// a command — and the caller states the shape and shows an example instead. +fn value_rows( + root: &std::path::Path, + config: &io_harness::config::Config, + key: &str, +) -> Option<(Picker, Pick)> { + let kind = io_cli::configure::kind_of(key)?; + let setting = io_cli::configure::setting(config, key); + let current = setting.value.clone(); + let bare = current + .as_deref() + .map(|value| value.trim().trim_matches('"').to_string()); + let values: Vec = match &kind { + io_cli::configure::Kind::Flag => vec!["true".to_string(), "false".to_string()], + io_cli::configure::Kind::Choice(options) => options.clone(), + io_cli::configure::Kind::Number { signed } => { + let anchor = bare.as_deref().and_then(|value| value.parse::().ok()); + io_cli::configure::ladder(anchor, *signed) + .into_iter() + .map(|rung| rung.to_string()) + .collect() + } + io_cli::configure::Kind::Model => io_cli::configure::priced_models(root), + 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 + // and the policy that hides a file hides it in both places. + let policy = io_harness::Policy::default(); + io_cli::complete::entries(root, &policy, "") + .ok() + .map(|(found, _)| { + found + .iter() + .map(|entry| entry.path.clone()) + .collect::>() + }) + .unwrap_or_default() + } + // Typed, and the caller says what shape. + io_cli::configure::Kind::List + | io_cli::configure::Kind::Text + | io_cli::configure::Kind::Machine => return None, + }; + let (scope, inherited) = io_cli::configure::destination(config, key); + let word = io_cli::configure::Decided::File { + scope, + path: Default::default(), + }; + let mut rows = vec![Row::new("leave it".to_string())]; + for value in &values { + // The value in force is marked rather than omitted or reordered: a list + // that hid what the file currently says is a list an operator cannot find + // their own setting in. + let detail = if bare.as_deref() == Some(value.as_str()) { + format!("in force now, from the {} scope", setting.decided.word()) + } else { + String::new() + }; + rows.push(Row::with_detail(value.clone(), detail)); + } + let unset_at = rows.len(); + rows.push(Row::with_detail( + "unset it".to_string(), + "removes the key, so io-harness's own default is in force and the origin says `default`" + .to_string(), + )); + let elsewhere_at = rows.len(); + rows.push(Row::with_detail( + "write it to another file…".to_string(), + "moves the key between the three scopes".to_string(), + )); + // **The scope is in the title, so it is stated before the choice and not + // after it.** A write inherits the file already deciding the key; answering + // "the user scope" every time would silently shadow a committed project + // setting with a personal one. + let title = if inherited { + format!("{key} — writes to the {} file, which decides it", word.word()) + } else { + format!("{key} — no file names it, so this writes to the {} file", word.word()) + }; + Some(( + Picker::new(title, rows), + Pick::ConfigValue { + key: key.to_string(), + kind, + values, + scope, + unset_at, + elsewhere_at, + }, + )) +} + +/// Cycle one `/config` row's value where it stands, and redraw the list around it. +/// +/// **Every decision here is a library call**; what is in this function is the +/// wiring, which is all this file may hold. `configure::cycled` decides whether +/// the kind can be cycled at all and what the next value is, `configure:: +/// destination` decides which file it lands in, `configure::spell_value` decides +/// how it is written, and `configure::widens_project` decides whether that file +/// will accept it. +/// +/// The row is rebuilt from a re-read configuration rather than patched in place, +/// so the value and the origin column on screen are the file's own answer and not +/// this function's account of what it just did. `selecting` keeps the marker on +/// the row the operator is holding an arrow on. +#[allow(clippy::too_many_arguments)] +fn cycle_setting( + root: &std::path::Path, + config: &mut io_harness::config::Config, + app: &mut io_cli::app::App, + key: &str, + forward: bool, + open: &mut Picker, + selecting: usize, +) { + let Some(kind) = io_cli::configure::kind_of(key) else { + // A key the catalogue does not name has no kind and cannot be cycled. + // Silence would read as a broken key, so it says what to do instead. + app.record( + Tone::Muted, + format!("{key} is not a key io-cli knows the values of; press Enter to type one"), + ); + return; + }; + let setting = io_cli::configure::setting(config, key); + let Some(next) = io_cli::configure::cycled(&kind, setting.value.as_deref(), forward) else { + app.record( + Tone::Muted, + format!("{key} is chosen from a list rather than cycled; press Enter to open it"), + ); + return; + }; + let (scope, inherited) = io_cli::configure::destination(config, key); + // **Said before it is attempted, because the cost is not one key.** + // `refuse_widening` runs before deserialization, so a widening value in a + // committed file makes the whole file stop parsing rather than that setting + // being rejected. `write` would catch it and report io-harness's own sentence + // — this reports it without touching the file at all. + if scope == io_harness::config::Scope::Project + && io_cli::configure::widens_project(key, &next) + { + app.record( + Tone::Refused, + format!( + "{key} is decided by the project file, and a committed file may not set it to \ + {next} — io-harness refuses the whole file for it, not just the key. Choose \ + another value, or move the key to another file with `/config {key} {next}`." + ), + ); + return; + } + let was = setting.value.clone().unwrap_or_else(|| "default".to_string()); + let edit = io_cli::edit::Edit::set(key, io_cli::configure::spell_value(&kind, &next)); + match io_cli::configure::write(root, scope, &[edit]) { + Err(refusal) => app.record(Tone::Refused, refusal), + Ok(()) => { + match io_cli::configure::reload(root) { + Ok((fresh, _)) => *config = fresh, + Err(error) => app.record(Tone::Error, error), + } + let settings = io_cli::configure::settings(config); + let mut rows = io_cli::configure::rows(&settings); + rows.push(io_cli::configure::refresh_row(&io_cli::configure::setting( + config, + "prices.as_of", + ))); + open.set_rows(rows, selecting); + // **`record`, not `say`.** A one-slot footer notice is overwritten by + // the next arrow press, and an operator cycling through four values + // would end with three of the four changes unrecorded anywhere. This + // is the rule 0.26.0 wrote down and 0.27.0 shipped a violation of. + app.record( + Tone::Success, + format!( + "{key}: {was} → {next}, in the {} scope{}", + io_cli::configure::Decided::File { + scope, + path: Default::default(), + } + .word(), + if inherited { + ", which already decided it" + } else { + ", which is where a key no file named goes" + } + ), + ); + } + } +} + fn write_where(root: &std::path::Path, key: String, value: String) -> (Picker, Pick) { let paths: Vec<(io_harness::config::Scope, std::path::PathBuf)> = [ io_harness::config::Scope::User, diff --git a/tests/values.rs b/tests/values.rs new file mode 100644 index 0000000..791d761 --- /dev/null +++ b/tests/values.rs @@ -0,0 +1,363 @@ +//! F3, F12 and F13 — a value chosen on the surface, the file it lands in, and the +//! promise that no surface ever opens a bare composer again. +//! +//! **Every claim about what a written value means goes back through +//! `Config::discover`**, never through `edit::value_at`. Quoting back the bytes +//! just written proves only that the splice ran; the question is whether +//! io-harness reads what the surface displayed, and only io-harness can answer it. +//! That distinction is F3's own sabotage arm — writing an enum's *label* instead +//! of its serialized value passes a bytes-level check and fails here. +//! +//! Nothing here writes to the developer's own `~/.io-cli`. Every configuration +//! write goes to a temporary root, and the user file is written *outside* the +//! workspace, because a file inside it is project-scoped whatever variable names +//! it — a fact this repository paid two live runs for in 0.14.0. + +use std::sync::{Mutex, MutexGuard}; + +use io_cli::configure::{self, Kind}; +use io_harness::config::{Config, Scope}; + +/// `Config::discover` reads `IO_CONFIG` at call time, so two tests setting it at +/// once would each see the other's file. +fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: Mutex<()> = Mutex::new(()); + LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +struct Scopes { + _home: tempfile::TempDir, + root: tempfile::TempDir, + user: std::path::PathBuf, +} + +fn scopes(user: &str, project: &str, local: &str) -> Scopes { + let home = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let user_path = home.path().join("io.toml"); + std::fs::write(&user_path, user).unwrap(); + if !project.is_empty() { + std::fs::write(root.path().join("io.toml"), project).unwrap(); + } + if !local.is_empty() { + std::fs::write(root.path().join("io.local.toml"), local).unwrap(); + } + Scopes { + _home: home, + root, + user: user_path, + } +} + +impl Scopes { + fn config(&self) -> Config { + let _guard = env_lock(); + std::env::set_var("IO_CONFIG", &self.user); + let config = Config::discover(self.root.path()).unwrap(); + std::env::remove_var("IO_CONFIG"); + config + } +} + +// F3 — a value chosen on the surface is the value io-harness reads back. + +/// One key of each chooseable kind, written and re-read through the harness. +/// +/// Sabotage: write the label instead of the serialized value — under which the +/// enum arm fails here while a bytes-level check would pass, because +/// `theme = dark` unquoted is not a TOML string and `Config::discover` refuses it. +#[test] +fn f3_a_chosen_value_is_what_the_harness_reads_back() { + // (key, kind-provided value, what the harness must then say) + let cases: Vec<(&str, &str)> = vec![ + ("app.io-cli.theme", "light"), + ("policy.defaults.read", "deny"), + ("sandbox.mode", "read-only"), + ("run.max_steps", "20"), + ("app.io-cli.plain", "true"), + ]; + for (key, value) in cases { + let s = scopes("", "", ""); + let kind = configure::kind_of(key).unwrap_or_else(|| panic!("{key} has no kind")); + let spelled = configure::spell_value(&kind, value); + let edit = io_cli::edit::Edit::set(key, spelled); + configure::write(s.root.path(), Scope::Project, &[edit]) + .unwrap_or_else(|error| panic!("{key}: {error}")); + + // The harness's own discovery, never `value_at`. + let config = s.config(); + let after = configure::setting(&config, key); + assert_eq!( + after.value.as_deref().map(|v| v.trim().trim_matches('"')), + Some(value), + "{key} read back as something other than what was chosen" + ); + } +} + +/// A number is written bare and a string is written quoted. +/// +/// The two halves of the same sabotage: quoting a number makes `run.max_steps` a +/// string the schema refuses, and not quoting an enum makes it a bare token TOML +/// cannot parse. +#[test] +fn f3_a_value_is_spelled_the_way_its_kind_is_written() { + assert_eq!( + configure::spell_value(&Kind::Number { signed: false }, "20"), + "20" + ); + assert_eq!(configure::spell_value(&Kind::Flag, "true"), "true"); + assert_eq!( + configure::spell_value(&Kind::Choice(vec!["dark".into()]), "dark"), + "\"dark\"" + ); + // A list goes through the one renderer that already knows how. + let rendered = configure::spell_value(&Kind::List, "cargo test --all"); + assert!( + rendered.starts_with('[') && rendered.contains("\"cargo\""), + "a list must be written as a TOML array: {rendered}" + ); +} + +/// A list value round-trips as a list io-harness can read back. +/// +/// `app.io-cli.gates.command` is `Option>`; a scalar written there is +/// a value the harness cannot read at all, which is why it has its own kind. +#[test] +fn f3_the_command_list_reads_back_as_a_list() { + let s = scopes("", "", ""); + let key = "app.io-cli.gates.command"; + let spelled = configure::spell_value(&Kind::List, "cargo test --all"); + configure::write( + s.root.path(), + Scope::Project, + &[io_cli::edit::Edit::set(key, spelled)], + ) + .expect("the list writes"); + + let config = s.config(); + let (stored, _) = io_cli::settings::stored(&config); + let command = stored + .and_then(|s| s.gates) + .and_then(|g| g.command) + .expect("io-harness reads the command back as a list"); + assert_eq!(command, vec!["cargo", "test", "--all"]); +} + +// F12 — no surface opens the composer with a bare key and no candidates. + +/// Every catalogue key either offers its values or states its shape. +/// +/// The criterion in one assertion. Sabotage: restore the old prefill for one key +/// — under which F12 fails by naming that key, because that key would resolve to a +/// typed kind with no shape sentence behind it. +#[test] +fn f12_every_key_offers_options_or_states_a_shape() { + let s = scopes("", "", ""); + let config = s.config(); + let mut bare: Vec<&str> = Vec::new(); + for key in configure::CATALOGUE { + let offers = matches!( + configure::kind_of(key), + Some(Kind::Flag) + | Some(Kind::Choice(_)) + | Some(Kind::Number { .. }) + | Some(Kind::Model) + | Some(Kind::File) + ); + if !offers && configure::shape_of(key, &config).is_none() { + bare.push(key); + } + } + assert!( + bare.is_empty(), + "these keys would open a bare composer with no candidates and no stated shape: {bare:?}" + ); +} + +/// A stated shape carries a worked example, not just a noun. +/// +/// "a URL" is not a shape an operator can act on; "for example: https://…" is. +#[test] +fn f12_a_stated_shape_shows_an_example() { + let s = scopes("", "", ""); + let config = s.config(); + for key in [ + "app.io-cli.gates.command", + "app.io-cli.gates.contains", + "app.io-cli.gates.rubric", + "app.io-cli.prices.source_url", + ] { + let said = configure::shape_of(key, &config) + .unwrap_or_else(|| panic!("{key} states no shape")); + assert!( + said.contains("for example:"), + "{key} states a shape with no worked example: {said}" + ); + } +} + +/// The machine-written key says so rather than offering to be typed. +#[test] +fn f12_the_machine_written_key_says_it_is_not_typed() { + let s = scopes("", "", ""); + let said = configure::shape_of("prices.as_of", &s.config()).expect("it says something"); + assert!( + said.contains("rather than typed"), + "prices.as_of must say it is written by machinery: {said}" + ); +} + +// F13 — a write lands in the file that already decides the key. + +/// A key the project file decides is written back into the project file. +/// +/// Sabotage: always write the user scope — under which this fails, and the failure +/// is the change an operator is least able to see: a personal file silently +/// shadowing a committed one. +#[test] +fn f13_a_write_inherits_the_deciding_file() { + let s = scopes( + "[run]\nmax_steps = 10\n", + "[app.io-cli]\ntheme = \"dark\"\n", + "", + ); + let config = s.config(); + assert_eq!( + configure::destination(&config, "app.io-cli.theme"), + (Scope::Project, true) + ); + assert_eq!( + configure::destination(&config, "run.max_steps"), + (Scope::User, true) + ); +} + +/// A key no file names goes to the operator's own file, and says it was not +/// inherited. +#[test] +fn f13_a_key_no_file_names_goes_to_the_user_scope() { + let s = scopes("", "", ""); + assert_eq!( + configure::destination(&s.config(), "app.io-cli.diff"), + (Scope::User, false) + ); +} + +/// The local file wins over the project file, because that is the precedence in +/// force — the destination follows what decided the key, not a fixed order. +#[test] +fn f13_the_deciding_file_is_the_one_actually_in_force() { + let s = scopes( + "", + "[app.io-cli]\ntheme = \"dark\"\n", + "[app.io-cli]\ntheme = \"light\"\n", + ); + let config = s.config(); + assert_eq!( + configure::destination(&config, "app.io-cli.theme"), + (Scope::Local, true) + ); +} + +// The cycling keys, and the property that makes them possible. + +/// A boolean and a closed enum cycle; a number does not. +/// +/// A number's ladder cannot be shown on the row it is changed on, and a held arrow +/// would write the file once per key repeat. +#[test] +fn n4_only_booleans_and_closed_enums_cycle_in_place() { + assert_eq!( + configure::cycled(&Kind::Flag, Some("false"), true).as_deref(), + Some("true") + ); + assert_eq!( + configure::cycled(&Kind::Flag, Some("true"), true).as_deref(), + Some("false"), + "a closed set wraps rather than stopping" + ); + let effects = Kind::Choice(configure::effects()); + assert_eq!( + configure::cycled(&effects, Some("allow"), true).as_deref(), + Some("ask") + ); + assert_eq!( + configure::cycled(&effects, Some("allow"), false).as_deref(), + Some("deny"), + "backwards from the first option wraps to the last" + ); + for kind in [ + Kind::Number { signed: false }, + Kind::Model, + Kind::File, + Kind::List, + Kind::Text, + Kind::Machine, + ] { + assert_eq!( + configure::cycled(&kind, Some("x"), true), + None, + "{kind:?} must not cycle in place" + ); + } +} + +/// An unset boolean turns on with one press of the forward key. +#[test] +fn n4_an_unset_flag_enters_the_list_at_its_far_end() { + assert_eq!( + configure::cycled(&Kind::Flag, None, true).as_deref(), + Some("true"), + "forward on an unset flag enters at the last option, which turns it on" + ); + assert_eq!( + configure::cycled(&Kind::Flag, None, false).as_deref(), + Some("false"), + "and backward enters at the first" + ); +} + +/// **The property the whole binding rests on: the picker's filter keeps every +/// printable character, the space included.** +/// +/// The owner's literal request was toggles on the spacebar, and it cannot be +/// built: `Picker` consumes every printable as a fuzzy filter, so a two-word query +/// would toggle a setting on its own space. This asserts the filter still owns the +/// space, which is what makes the arrows the right binding rather than a +/// preference. +/// +/// Sabotage: bind the cycle to Space. Under it this fails, and it fails on the +/// query an operator types to find a key among thirty-seven. +#[test] +fn n4_the_picker_filter_still_owns_the_space() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use io_cli::picker::{Picker, Row}; + + let mut picker = Picker::new( + "Which setting?", + vec![ + Row::new("app.io-cli.theme".to_string()), + Row::new("run.max_steps".to_string()), + ], + ); + for character in "max steps".chars() { + picker.key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE)); + } + assert_eq!( + picker.query(), + "max steps", + "the space must reach the query, or a two-word search is impossible" + ); + + // And the arrows the cycling uses are still free: neither moves the marker + // nor chooses a row, so intercepting them takes nothing from any surface. + let before = picker.selected(); + for code in [KeyCode::Left, KeyCode::Right] { + assert!(matches!( + picker.key(KeyEvent::new(code, KeyModifiers::NONE)), + io_cli::picker::Outcome::Idle + )); + } + assert_eq!(picker.selected(), before, "an arrow must not move the marker"); +} From 7375290856da25e1f4ca63578d12c87741b8085e Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 10:16:28 +0530 Subject: [PATCH 06/15] =?UTF-8?q?chore(develop):=20`providers::edit`=20and?= =?UTF-8?q?=20the=20three=20credential=20shapes,=20in=20t=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.28.0-T12 --- src/providers.rs | 256 +++++++++++++++++++++++++++++++- tests/providers.rs | 360 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 613 insertions(+), 3 deletions(-) diff --git a/src/providers.rs b/src/providers.rs index 6729807..e12275d 100644 --- a/src/providers.rs +++ b/src/providers.rs @@ -50,6 +50,32 @@ //! round trip and roll back, which is a good failure — but [`add`] takes an //! [`Endpoint`] whose two `compatible` shapes are separate variants, so the entry //! that fails cannot be constructed at all. +//! +//! # Changing a link rather than replacing it +//! +//! A key rotates and a model is renamed, and until [`edit`] existed neither was +//! reachable from this surface: an operator whose `OPENROUTER_API_KEY` changed +//! could add a link, promote it, demote it and remove it, and to change one word +//! of one had to open `io.toml`. [`edit`] is `servers::edit`'s twin — one key of +//! one addressed entry, `value` as TOML source — and it is deliberately narrower +//! than `ProviderSpec` is wide: [`KEYS`] is `model` and `api_key` and nothing +//! else. `kind`, `preset` and `base_url` are the link's **identity**, and an +//! entry that reaches a different vendor is a different link; [`remove`] and +//! [`add`] say that out loud, and they cannot leave behind the both-bases entry +//! `preset = "groq"` written over a `base_url` entry would be. +//! +//! # The three shapes a credential has, and which one is the default +//! +//! [`Key`] is the write side of [`Credential`]: the same three shapes the file +//! format already has, spelled as a choice rather than as a string a caller +//! assembles. **[`Key::Environment`] is the default that matters** — it is the +//! only one of the three under which `io.toml` never holds the secret at all. +//! [`variable`] names the environment variable a given endpoint would read and +//! [`variable_is_set`] says whether it currently has one, so a caller can offer +//! "use `$OPENROUTER_API_KEY`, which is already set" as the row that is already +//! selected. Neither of them, and nothing else in this module, ever returns the +//! variable's contents: what is being decided is *where a key lives*, and that +//! question is answerable without reading one. use io_harness::config::{Config, Scope}; use io_harness::{Compatible, ProviderSpec}; @@ -492,6 +518,231 @@ impl Endpoint<'_> { } } +/// How a new or changed credential is supplied — the write side of +/// [`Credential`]. +/// +/// The same three shapes the file format already has, offered as a choice rather +/// than as a string every call site assembles for itself. A caller that built the +/// text by hand would be one `format!` away from writing `${env:GROQ_API_KEY}` +/// without the braces, or a literal where an indirection was meant, and the +/// second of those is a secret in a file the operator did not ask to hold one. +/// +/// **[`Key::Environment`] is the shape to default to.** It is the only one under +/// which the key is never in `io.toml` — nothing to leak in a screenshot, a +/// backup, a `git add -A`, or a support paste. The other two are what an operator +/// asks for, and a literal is what they should have to ask for twice. +/// +/// It is `Copy` and borrows its text, because a credential should live in this +/// process for as long as it takes to write it and no longer. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Key<'a> { + /// No key in the file, so the endpoint's own environment variable answers. + /// + /// **What that means depends on the endpoint, and the difference is not + /// cosmetic.** For `openrouter`, `anthropic` and `openai` io-harness reads + /// the vendor's variable itself when `api_key` is absent, so the shape is a + /// key that is *not written*. For a `compatible` entry there is no variable + /// to fall back to — io-harness says so in the variant's own documentation — + /// so the same intention has to be written as `${env:…}`, and an absent key + /// there means an endpoint that needs none. [`Key::written`] resolves that, + /// once, rather than leaving every caller to know it. + Environment, + /// A `${env:VAR}` or `${file:PATH}` reference, written literally. + /// + /// io-harness substitutes it as it parses, and **errors outright when the + /// variable is unset** — so a reference to a name that does not exist is + /// refused by [`crate::configure::write`]'s round trip and rolled back. + Indirect(&'a str), + /// The key itself, written into the file. + /// + /// The shape that puts a secret on disk. Nothing here refuses it — an + /// operator with no environment to lean on is a real operator — but it is the + /// shape a caller must have been told to write, naming the file it lands in. + Literal(&'a str), +} + +// **Hand-written, because `derive(Debug)` on this type prints the key.** A +// `Key` is one `{:?}` away from a log line, a panic message, or an +// `assert_eq!` failure in somebody's CI output, and a derived `Debug` would put +// the literal in all three. The variant is the part worth seeing; the bytes are +// never the part worth seeing. +impl std::fmt::Debug for Key<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Key::Environment => f.write_str("Environment"), + // The variable's NAME is information and its contents are not, which + // is the same line `Credential::Indirect` and `configure::redact` + // already draw. + Key::Indirect(text) => write!(f, "Indirect({text:?})"), + Key::Literal(_) => f.write_str("Literal()"), + } + } +} + +impl Key<'_> { + /// What [`add`] writes as `api_key`, or `None` for no `api_key` line at all. + /// + /// The one place the `Environment` split above is resolved. For the three + /// vendor kinds it is `None`, which is the absence io-harness reads as "use + /// my own variable". For a preset it is `${env:VAR}` for the variable + /// [`variable`] names — because a `compatible` entry has no fallback, and an + /// absent key there would silently be an unauthenticated request rather than + /// an authenticated one. For a bare `base_url`, and for the local runtimes, + /// there is no variable to name and `None` is the honest answer: those are + /// the endpoints that genuinely need no key. + pub fn written(&self, endpoint: Endpoint<'_>) -> Option { + match self { + Key::Environment => match endpoint { + Endpoint::OpenRouter | Endpoint::Anthropic | Endpoint::OpenAi => None, + Endpoint::Preset(_) | Endpoint::BaseUrl(_) => { + variable(endpoint).map(|var| format!("${{env:{var}}}")) + } + }, + Key::Indirect(text) => Some((*text).to_string()), + Key::Literal(key) => Some((*key).to_string()), + } + } + + /// What [`edit`] takes as `value` — TOML source, and `""` to unset. + /// + /// The pair to [`Key::written`] for an entry that already exists, and it is + /// two lines rather than a note at the call site for one reason: the caller + /// holding a `Key` has a Rust string and [`edit`] takes TOML, and the + /// obvious bridge — `format!("\"{key}\"")` — is either a parse error or a + /// different value the moment the key carries a quote or a backslash. + /// + /// An empty result is not an empty key. See [`edit`]: it is the request to + /// take the line away. + pub fn source(&self, endpoint: Endpoint<'_>) -> String { + self.written(endpoint) + .map(|text| quoted(&text)) + .unwrap_or_default() + } +} + +/// The environment variable an endpoint's credential would come from. +/// +/// For the three vendor kinds these are io-harness's own — the names its +/// `from_env` constructors read, which is why a shell that already works with the +/// harness works here — and [`crate::provider::key_for`] falls back to exactly +/// these. +/// +/// For a preset the name is **derived**, `_API_KEY`, and that is a +/// deliberate choice over a hand-kept table of thirteen. io-harness has no +/// variable for `compatible` at all, so there is nothing here for a table to be +/// checked against, and a list nothing can disagree with is decoration that goes +/// stale in silence — the same argument [`PRESETS`] answers by being provable. +/// The derivation matches what io-harness's own documentation writes +/// (`config.rs:430` spells `${env:GROQ_API_KEY}`), and it is an *offer*: a +/// vendor whose variable is spelled differently is one [`Key::Indirect`] away, +/// and [`variable_is_set`] means a name nobody's shell carries is never the +/// default that gets taken. +/// +/// `None` where there is no variable to name — the eight local runtimes and a +/// bare `base_url`, which need no credential at all. +pub fn variable(endpoint: Endpoint<'_>) -> Option { + match endpoint { + Endpoint::OpenRouter => Some("OPENROUTER_API_KEY".to_string()), + Endpoint::Anthropic => Some("ANTHROPIC_API_KEY".to_string()), + Endpoint::OpenAi => Some("OPENAI_API_KEY".to_string()), + Endpoint::Preset(name) if !is_local(name) => Some(format!( + "{}_API_KEY", + name.to_uppercase() + .replace(|c: char| !c.is_ascii_alphanumeric(), "_") + )), + Endpoint::Preset(_) | Endpoint::BaseUrl(_) => None, + } +} + +/// Whether `variable` currently holds a non-empty value. +/// +/// **Whether, and never what.** This is the whole of what the surface above it +/// needs: an offer reads "use `$OPENROUTER_API_KEY`, which is already set", and +/// the sentence is complete without the key ever entering this process — let +/// alone a row, a log line or a `{:?}`. Returning the value would put a secret +/// into every caller that only wanted to know whether to preselect a row. +/// +/// Empty counts as unset, for the reason [`edit`] refuses to write one: an empty +/// key is a key [`crate::provider::key_for`] hands back as valid, and every +/// request then fails authentication with nothing to read. +pub fn variable_is_set(variable: &str) -> bool { + std::env::var(variable).is_ok_and(|value| !value.trim().is_empty()) +} + +/// The keys of a `[[provider]]` entry [`edit`] will change. +/// +/// **Narrower than `ProviderSpec` is wide, on purpose.** The variant carries +/// seven fields; two of them are what an operator wants to change about a link +/// they already have, and the other five are either its identity or a setting no +/// verb on this surface asks for: +/// +/// - `model` — renamed, deprecated, or simply the wrong one. The common edit. +/// - `api_key` — rotated. The edit this surface existed without, and the one an +/// operator was opening `io.toml` by hand to make. +/// - `kind`, `preset`, `base_url` — the link's **identity**. An entry pointed at +/// a different vendor is a different link, and [`remove`] plus [`add`] says so +/// in words. Allowing them here would also make the one entry io-harness +/// refuses expressible again: a `preset` written onto a `base_url` entry names +/// both bases, which fails at load by index (`config.rs:456`) — a loud failure, +/// but a loud failure produced by a control that looked like renaming a field. +/// - `auth`, `name`, `reference_prices` — nothing asks for them, and the last +/// turns on an outbound request to a host the file did not name. A control +/// that does that belongs on the surface that shows what it costs. +/// +/// Unlike `servers::KEYS` this list is not the only thing standing between a +/// typo and a silently ignored key — `[[provider]]` *is* held to +/// `deny_unknown_fields`, so `modle = "…"` is refused at load and +/// [`crate::configure::write`] rolls it back. The `const` is here so a caller +/// cannot spell a key at all, and so the five omissions above are a decision with +/// a reason written beside it rather than an accident of what got implemented. +/// +/// ponytail: no `base_url` even for an entry that already has one. Add it when +/// a moved gateway is a thing an operator actually reports, and pair it with the +/// exactly-one check [`Endpoint`] enforces at add time. +pub const KEYS: &[&str] = &["model", "api_key"]; + +/// The edit that changes one key of the entry at `at`. +/// +/// `value` is TOML **source**, the way [`crate::edit::Edit::set`] takes it — +/// `"\"gpt-4o\""`, `"\"${env:GROQ_API_KEY}\""`. Build it with [`Key::source`] or +/// [`crate::servers::quoted`] rather than a format string: `api_key` is pasted +/// text, and a raw newline or a backslash inside a hand-built basic string is a +/// parse error rather than a value. +/// +/// `None` for a key that is not one of [`KEYS`], so a caller cannot invent one. +/// +/// # An empty value unsets the key, and only `api_key` may be unset +/// +/// **`api_key` with an empty `value` deletes the line rather than writing an +/// empty string**, through [`crate::edit::Edit::unset`]. This is the one place +/// the distinction is load-bearing: moving a link back from a written literal to +/// its environment variable means the key must be *absent*, because that absence +/// is exactly what io-harness reads as "use the vendor's own variable". +/// `api_key = ""` is not that. It is a key that is set, to nothing: +/// [`crate::provider::key_for`] returns it as a valid credential, the request +/// carries an empty bearer token, and the vendor answers 401 for a reason no +/// message in this program will ever name. Both spellings of empty — no text at +/// all, and the TOML source `""` — are read as the unset, because the second is +/// precisely what a caller building a value from a cleared input field produces. +/// +/// `model` cannot be unset, and gets `None` for an empty value rather than an +/// edit: it is required on every variant, so removing it makes an entry that no +/// longer loads. That is caught on the round trip and rolled back, but a verb +/// that can only ever fail is better refused where it is spelled. +pub fn edit(at: &At, key: &str, value: &str) -> Option { + if !KEYS.contains(&key) { + return None; + } + let path = format!("provider[{}].{key}", at.index); + let trimmed = value.trim(); + let empty = trimmed.is_empty() || trimmed == "\"\"" || trimmed == "''"; + match (key, empty) { + ("api_key", true) => Some(Edit::unset(path)), + (_, true) => None, + _ => Some(Edit::set(path, value.to_string())), + } +} + /// The edit that appends a provider to the end of the chain. /// /// Last, because the end of an array of tables is the end of the chain and the @@ -499,7 +750,10 @@ impl Endpoint<'_> { /// change which vendor the next turn bills to, which is not what "add" says. /// /// `api_key` is written as given, so `Some("${env:GROQ_API_KEY}")` is how a -/// credential is named without being copied into the file. **io-harness resolves +/// credential is named without being copied into the file — and [`Key::written`] +/// is what a caller holding a chosen shape should build that argument with, +/// because "from the environment" is an absent key for a vendor kind and an +/// `${env:…}` for a preset. **io-harness resolves /// that at parse time and fails outright when the variable is unset**, so a /// reference to a name that does not exist is refused by /// [`crate::configure::write`]'s round trip and rolled back — the file is never diff --git a/tests/providers.rs b/tests/providers.rs index c479d5d..8d8734b 100644 --- a/tests/providers.rs +++ b/tests/providers.rs @@ -1,8 +1,9 @@ -//! F7, F8 and F9 — the chain, the presets, and the live verification. +//! F7, F8, F9 and F10 — the chain, the presets, the live verification, and +//! changing a link that already exists. use std::sync::{Mutex, MutexGuard}; -use io_cli::providers::{self, At, Credential, Endpoint}; +use io_cli::providers::{self, At, Credential, Endpoint, Key}; use io_harness::config::{Config, Scope}; const THREE: &str = "\ @@ -538,3 +539,358 @@ fn f7_a_profiles_link_is_never_addressed_by_a_position_in_the_top_level_array() assert_eq!(at.index(), entry.index); } } + +/// An entry whose credential is a literal, which is what a rotation edits. +const WRITTEN: &str = "\ +[[provider]] +kind = \"openai\" +model = \"gpt-4o\" +api_key = \"sk-supersecret-literal\" +"; + +#[test] +fn f10_editing_a_model_changes_one_key_and_leaves_the_file_alone() { + // The narrow claim, asserted at the only resolution that proves it: the + // whole file, byte for byte, is the original with one value replaced. A + // sibling entry rewritten, a comment lost, or a blank line normalised all + // fail here — and each of those is what a surface that rebuilt the array + // from io-cli's model of it would do, silently, on every edit. + let at = At::of(Scope::User, THREE, 1).expect("the middle link"); + let edit = providers::edit(&at, "model", &io_cli::servers::quoted("llama-3.1-8b-instant")) + .expect("`model` is one of the keys this surface changes"); + let after = io_cli::edit::apply(THREE, &[edit]).unwrap(); + + assert_eq!( + after, + THREE.replace("llama-3.3-70b-versatile", "llama-3.1-8b-instant"), + "the edit reached past the one value it names", + ); + + // And io-harness reads back the link the operator meant, in its own place. + let chain = providers::chain(&config(&after).config); + assert_eq!(chain[1].model, "llama-3.1-8b-instant"); + assert_eq!(chain[1].kind, "groq", "the edit changed the entry's identity"); + assert_eq!(chain[0].kind, "openrouter"); + assert_eq!(chain[2].model, "llama3.2"); + assert_eq!( + chain[1].credential, + Credential::Indirect("${env:IO_CLI_TEST_GROQ_KEY}".into()), + "editing the model disturbed the credential beside it", + ); +} + +#[test] +fn f10_a_credential_is_written_in_whichever_of_the_three_shapes_was_chosen() { + // Shape one — from the environment. For a vendor kind this is the ABSENCE of + // the key, and the absence is the whole value of the shape: there is nothing + // in `io.toml` to leak. Asserted as absent rather than as empty, because + // `api_key = ""` reads back as a key that is set. + let written = Key::Environment.written(Endpoint::OpenRouter); + assert!( + written.is_none(), + "a vendor kind was given text to write, so the secret-free shape writes \ + something into the file after all", + ); + let after = io_cli::edit::apply( + "", + &[providers::add( + Endpoint::OpenRouter, + "m", + written.as_deref(), + )], + ) + .unwrap(); + assert!(!after.contains("api_key"), "an empty credential: {after}"); + assert_eq!( + providers::chain(&config(&after).config)[0].credential, + Credential::FromEnvironment("OPENROUTER_API_KEY"), + ); + + // The same intention on a `compatible` entry is a DIFFERENT spelling, and + // this is the split `Key::written` exists to resolve. There is no variable + // for io-harness to fall back to here, so an absent key would be an + // unauthenticated request rather than an authenticated one. + assert_eq!( + Key::Environment.written(Endpoint::Preset("groq")).as_deref(), + Some("${env:GROQ_API_KEY}"), + ); + // Except where there is genuinely no credential to name. + assert!(Key::Environment + .written(Endpoint::Preset("ollama")) + .is_none()); + assert!(Key::Environment + .written(Endpoint::BaseUrl("http://localhost:8080/v1")) + .is_none()); + + // Shape two — an indirection, written as written and read back as written. + let groq = Endpoint::Preset("groq"); + let indirect = Key::Indirect("${env:IO_CLI_TEST_GROQ_KEY}"); + let after = io_cli::edit::apply( + "", + &[providers::add( + groq, + "llama-3.3-70b-versatile", + indirect.written(groq).as_deref(), + )], + ) + .unwrap(); + assert_eq!( + providers::chain(&config(&after).config)[0].credential, + Credential::Indirect("${env:IO_CLI_TEST_GROQ_KEY}".into()), + "the variable's name is the information, and it did not survive", + ); + + // Shape three — a literal, which is the shape that puts a secret on disk. + const SECRET: &str = "sk-proj-supersecret-literal"; + let literal = Key::Literal(SECRET); + let after = io_cli::edit::apply( + "", + &[providers::add( + Endpoint::OpenAi, + "gpt-4o", + literal.written(Endpoint::OpenAi).as_deref(), + )], + ) + .unwrap(); + assert!( + after.contains(SECRET), + "a literal the operator asked for was not the key that was written", + ); + let chain = providers::chain(&config(&after).config); + assert_eq!(chain[0].credential, Credential::Written); + + // And having been written, it still reaches no rendered surface. + for row in providers::rows(&chain) { + let text = format!("{} {}", row.label, row.detail.unwrap_or_default()); + assert!(!text.contains(SECRET), "a credential reached a row: {text}"); + assert!(!text.contains("supersecret")); + } + assert!(!chain[0].credential.word().contains("supersecret")); +} + +#[test] +fn n2_a_key_never_prints_itself() { + // `Key` is one `{:?}` away from a log line, a panic message, or somebody's + // CI output on a failed `assert_eq!`. A derived `Debug` would put the + // literal in all three, and it is the sort of leak nothing fails on until it + // is already public. + const SECRET: &str = "sk-proj-supersecret-literal"; + let shown = format!("{:?}", Key::Literal(SECRET)); + assert!( + !shown.contains("supersecret") && !shown.contains(SECRET), + "a credential printed itself: {shown}", + ); + assert!(shown.contains("Literal"), "the shape is the useful part"); + + // The variable's NAME is not a secret and stays legible — the same line + // `configure::redact` draws. + assert!(format!("{:?}", Key::Indirect("${env:GROQ_API_KEY}")).contains("GROQ_API_KEY")); + assert_eq!(format!("{:?}", Key::Environment), "Environment"); +} + +#[test] +fn f10_unsetting_a_credential_deletes_the_line_rather_than_emptying_it() { + let at = At::of(Scope::User, WRITTEN, 0).expect("the only entry"); + let after = io_cli::edit::apply( + WRITTEN, + &[providers::edit(&at, "api_key", "").expect("`api_key` can be unset")], + ) + .unwrap(); + + // Absent, not empty — asserted through the same reader the panel shows the + // value with, so this is the question a surface would ask. + assert!( + io_cli::edit::value_at(&after, "provider[0].api_key").is_none(), + "the key is still in the file: {after}", + ); + assert!(!after.contains("api_key"), "{after}"); + assert!(!after.contains("supersecret"), "the literal survived: {after}"); + + // And the entry is still a link, which is the half a deletion could break: + // `unset` takes one line and leaves the `[[provider]]` around it standing. + let loaded = Config::from_toml(&after).expect("the entry still loads"); + let Some(io_harness::ProviderSpec::OpenAi { model, api_key }) = loaded.provider_spec() else { + panic!("the entry stopped being an openai provider"); + }; + assert_eq!(model, "gpt-4o"); + assert_eq!( + *api_key, None, + "no key written is what io-harness reads as `OPENAI_API_KEY`", + ); + + // A cleared input field spells empty as `""`, and that is the same request. + let same = io_cli::edit::apply( + WRITTEN, + &[providers::edit(&at, "api_key", "\"\"").expect("the same request, spelled in TOML")], + ) + .unwrap(); + assert_eq!(same, after); + + // **The sabotage, run rather than described.** Writing the empty string is + // what the obvious implementation does, and it does not fail loudly — it + // produces a key that IS set, to nothing. `provider::key_for` hands that + // back as a valid credential, the request carries an empty bearer token, and + // the vendor answers 401 for a reason nothing in this program will name. + let sabotage = io_cli::edit::apply( + WRITTEN, + &[io_cli::edit::Edit::set("provider[0].api_key", "\"\"")], + ) + .unwrap(); + assert!(sabotage.contains("api_key = \"\"")); + let sabotaged = + Config::from_toml(&sabotage).expect("an empty key still parses, which is the problem"); + let Some(io_harness::ProviderSpec::OpenAi { api_key, .. }) = sabotaged.provider_spec() else { + panic!("an openai provider"); + }; + assert_eq!( + api_key.as_deref(), + Some(""), + "an empty key is no longer read as a key that is set, so the unset above \ + is no longer the thing standing between an operator and a silent 401", + ); + + // `model` is required on every variant, so it has no unset: a verb that can + // only ever produce an entry that fails to load is refused where it is + // spelled rather than on the round trip. + assert!(providers::edit(&at, "model", "").is_none()); +} + +#[test] +fn f10_an_edit_names_a_key_this_surface_knows() { + let at = At::of(Scope::User, WRITTEN, 0).expect("the only entry"); + + assert_eq!( + providers::KEYS, + ["model", "api_key"].as_slice(), + "the editable keys changed; `KEYS`'s own documentation argues for each of \ + the five omissions, so a change here needs that argument changed with it", + ); + assert!(providers::edit(&at, "model", "\"gpt-4o-mini\"").is_some()); + assert!(providers::edit(&at, "api_key", "\"${env:OPENAI_API_KEY}\"").is_some()); + + for key in [ + // The link's identity. A different vendor is a different link, and + // `preset` written onto a `base_url` entry is the both-bases entry + // io-harness refuses by index at load. + "kind", + "preset", + "base_url", + // Nothing on this surface asks for them, and `reference_prices` turns on + // an outbound request to a host the file did not name. + "auth", + "name", + "reference_prices", + // A typo, and the empty string, which is what an unguarded prompt returns. + "modle", + "api-key", + "", + ] { + assert!( + providers::edit(&at, key, "\"x\"").is_none(), + "`{key}` was accepted as a key of a `[[provider]]` entry", + ); + } +} + +#[test] +fn f10_an_edit_is_aimed_at_the_array_the_row_came_from() { + // The 0.21.0 hazard, on the new verb. Under a profile the rows on screen and + // the array a write would splice are different lists, and every positional + // check still passes — so there must be no `At` to hand `edit` at all. + let fixture = config(PROFILE_REPLACES_THE_CHAIN); + let overlaid = io_cli::configure::with_profile(&fixture.config, "fast") + .expect("the fixture declares the profile"); + let shown = providers::chain(&overlaid); + assert_eq!(shown.len(), 1, "the profile no longer replaces the chain"); + assert!( + providers::declared_at(&overlaid, &shown[0]).is_none(), + "a profile's row answered with a position in the top-level array, so an \ + edit aimed at it would rewrite `the-operators-real-primary`", + ); + + // The control, and the half that says the guard is not simply refusing + // everything: with no profile in force the row and the array agree, and the + // write lands on the entry the row named — the second one. + let plain = providers::chain(&fixture.config); + let at = providers::declared_at(&fixture.config, &plain[1]) + .expect("a top-level link is addressable"); + let after = io_cli::edit::apply( + PROFILE_REPLACES_THE_CHAIN, + &[providers::edit(&at, "model", "\"a-different-fallback\"").expect("`model`")], + ) + .unwrap(); + + assert!( + after.contains("the-operators-real-primary"), + "the head was rewritten by an edit aimed at the first fallback", + ); + assert!( + !after.contains("the-operators-first-fallback"), + "the addressed entry was not the one that changed", + ); + assert!( + after.contains("the-operators-second-fallback") + && after.contains("something-small-and-quick"), + "an entry nobody named was rewritten", + ); + assert_eq!( + providers::chain(&config(&after).config)[1].model, + "a-different-fallback", + ); +} + +#[test] +fn f10_the_default_offered_is_the_variable_the_endpoint_already_reads() { + // The sabotage F10 names is defaulting to the literal, under which a secret + // lands in a file the operator did not ask to hold one. What makes the other + // default possible is being able to name the variable and say whether it is + // set — so both are asserted here, and neither of them ever produces the + // value. + assert_eq!( + providers::variable(Endpoint::OpenRouter).as_deref(), + Some("OPENROUTER_API_KEY"), + ); + assert_eq!( + providers::variable(Endpoint::Anthropic).as_deref(), + Some("ANTHROPIC_API_KEY"), + ); + assert_eq!( + providers::variable(Endpoint::OpenAi).as_deref(), + Some("OPENAI_API_KEY"), + ); + // The name io-harness's own documentation writes for this preset. + assert_eq!( + providers::variable(Endpoint::Preset("groq")).as_deref(), + Some("GROQ_API_KEY"), + ); + assert!( + providers::variable(Endpoint::BaseUrl("http://localhost:8080/v1")).is_none(), + "a bare base URL was offered a vendor's variable, which names no vendor", + ); + + // A credential is offered for exactly the endpoints that want one, which is + // the same split `is_local` already draws — thirteen hosted, eight local. + for preset in providers::PRESETS { + assert_eq!( + providers::variable(Endpoint::Preset(preset)).is_some(), + !providers::is_local(preset), + "{preset} was offered the wrong kind of credential row", + ); + } + + // Whether, never what. Held with the lock the fixtures use, and not while + // one is being built. + let _guard = env_lock(); + let var = "IO_CLI_TEST_DEFAULT_KEY"; + std::env::remove_var(var); + assert!(!providers::variable_is_set(var)); + std::env::set_var(var, " "); + assert!( + !providers::variable_is_set(var), + "an empty variable was reported as set, so the default row would point at \ + a credential that authenticates nothing", + ); + std::env::set_var(var, "gsk-not-a-real-key"); + assert!(providers::variable_is_set(var)); + std::env::remove_var(var); +} From 757af1d55c20917f55ed0a4de48fea2016f6513b Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 10:22:22 +0530 Subject: [PATCH 07/15] =?UTF-8?q?chore(develop):=20`/provider=20add`=20and?= =?UTF-8?q?=20`/provider=20edit`=20=E2=80=94=20preset,=20credenti=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.28.0-T08 --- src/main.rs | 408 +++++++++++++++++++++++++++++++++++++++++++-- tests/providers.rs | 47 ++++++ 2 files changed, 442 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index 08ef101..416364f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2098,7 +2098,70 @@ async fn loop_over Result>( // Says what the link is, and where it points. The chain // is arranged through `/config`, which is the one writer // this release gives the file. - Pick::Provider => { + // The add verb, checked first because `add_at` is past the + // end of the chain and every branch below indexes into it. + // + // **Only the presets whose own environment variable is + // already set.** A credential that has to be typed has one + // flow in this product — `io setup`, which types it, + // verifies it and writes it — and building a second one in + // the session loop is what this release's `preferred_tools` + // forbids by name. So the offer is the case that needs no + // typing at all, and the operator with no variable set is + // sent to the flow that already exists rather than to a + // half of it built here. + Pick::Provider { add_at } if index == *add_at => { + // **The three `FromEnv` covers, and no others.** + // `provider::spec_from` is the one constructor for a + // vendor spec outside the wizard handshake, and it + // takes a `FromEnv`; a preset outside those three has + // no spec this file may build, and building one here + // is what `tests/provider.rs` refuses by name. + let ready: Vec = ["openrouter", "anthropic", "openai"] + .into_iter() + .filter(|preset| { + io_cli::providers::variable(io_cli::providers::Endpoint::Preset( + preset, + )) + .is_some_and(|name| { + io_cli::providers::variable_is_set(&name) + }) + }) + .map(str::to_string) + .collect(); + if ready.is_empty() { + app.record( + Tone::Muted, + "no preset's API key variable is set in this shell, and a key \ + typed here would be a second credential flow beside `io \ + setup`'s. Export one — OPENROUTER_API_KEY, ANTHROPIC_API_KEY, \ + OPENAI_API_KEY — and this offers it, or run `io setup` to \ + type one." + .to_string(), + ); + } else { + let mut rows = vec![Row::new("leave it".to_string())]; + for preset in &ready { + let variable = io_cli::providers::variable( + io_cli::providers::Endpoint::Preset(preset), + ) + .unwrap_or_default(); + rows.push(Row::with_detail( + preset.clone(), + // The variable's NAME, never a word about + // its contents. A name identifies a + // credential; its value is not this + // surface's to show. + format!("uses ${variable}, which is set in this shell"), + )); + } + descended = Some(( + Picker::new("Add which provider?", rows), + Pick::ProviderPreset(ready), + )); + } + } + Pick::Provider { .. } => { let chain = io_cli::providers::chain(&config); if let Some(entry) = chain.get(index) { let place = if entry.index == 0 { @@ -2140,6 +2203,23 @@ async fn loop_over Result>( if !first { rows.push(Row::new("make this the provider in force")); } + // **The verb `/provider` has never had.** + // An operator whose key rotated, or who + // wants a different model on a link they + // already have, has until now had to open + // a file — while every other list in this + // product could be changed from the list. + // Its index is recorded rather than worked + // out on the keystroke, which is the rule + // the arithmetic below was already the + // argument for. + let model_at = rows.len(); + rows.push(Row::with_detail( + "change the model".to_string(), + "chooses from the catalogue this provider serves" + .to_string(), + )); + let remove_at = rows.len(); rows.push(Row::new("remove this link from the chain")); descended = Some(( Picker::new(format!("{}?", entry.model), rows), @@ -2147,6 +2227,9 @@ async fn loop_over Result>( label: entry.model.clone(), at, first, + kind: entry.kind.clone(), + model_at, + remove_at, }, )); } @@ -2159,9 +2242,255 @@ async fn loop_over Result>( // from rather than hard-coded, because those two drifting // apart is how a list removes the thing it offered to // promote. - Pick::ProviderVerb { label, at, first } => { + // **The verification call is made here, before any edit, + // and that ordering is the criterion.** A rejected + // credential must leave the configuration byte for byte as + // it was — writing first and verifying after would leave a + // key that cannot authenticate in the operator's file, and + // the next turn would fail with an error about the model. + // + // **Two round trips, not one.** `verify::credential` + // returns `Result<(), String>` and no models; the catalogue + // is a second call. A catalogue that fails does NOT abort + // the add — the credential was just accepted, and refusing + // an operator at that point over a list would be refusing + // them for being offline. + Pick::ProviderPreset(presets) => { + if let Some(preset) = + index.checked_sub(1).and_then(|at| presets.get(at)) + { + let preset = preset.clone(); + // **The catalogue first, then the model, then the + // credential — and that order is forced rather + // than chosen.** `verify::credential` pings the + // endpoint *with a model*, so verifying before one + // is chosen would either send a model the operator + // has not picked or report a 404 about a model as + // a bad credential. `verify::served` needs neither + // a credential nor a model, so the list comes + // first and the check follows it, still before any + // edit — which is what F10 actually asks for. + app.record( + Tone::Muted, + "reading the model catalogue…".to_string(), + ); + paint(screen, &mut app)?; + let models: Vec = io_cli::verify::served(None) + .await + .into_iter() + .map(|model| model.id) + .collect(); + if models.is_empty() { + // Not fatal, and deliberately so: a catalogue + // that cannot be read is a reason to make the + // operator name a model, never a reason to + // refuse an add they can complete offline. + app.record( + Tone::Muted, + format!( + "no catalogue was served, so there is no list to \ + choose {preset}'s model from; add the link with \ + `io setup`, which takes a typed model" + ), + ); + } else { + let mut rows = vec![Row::new("leave it".to_string())]; + for model in &models { + rows.push(Row::new(model.clone())); + } + descended = Some(( + Picker::new(format!("Which {preset} model?"), rows), + Pick::ProviderModel { + preset, + models, + at: None, + }, + )); + } + } + } + Pick::ProviderModel { preset, models, at } => { + if let Some(model) = index.checked_sub(1).and_then(|i| models.get(i)) { + let root = session.root().to_path_buf(); + let endpoint = io_cli::providers::Endpoint::Preset(preset); + // **The verification call, before any edit.** On a + // rejection the configuration file is unchanged + // byte for byte, because nothing has touched it + // yet — the ordering is the guarantee, not a check + // performed afterwards. Only for a new link: a + // change of model on a link that already exists is + // not a claim about a credential, and pinging the + // endpoint to change one field would spend an + // operator's money to answer a question nobody + // asked. + let refused = if at.is_none() { + app.record( + Tone::Muted, + format!("checking the {preset} credential…"), + ); + paint(screen, &mut app)?; + let which = match preset.as_str() { + "anthropic" => io_cli::cli::FromEnv::Anthropic, + "openai" => io_cli::cli::FromEnv::OpenAi, + _ => io_cli::cli::FromEnv::OpenRouter, + }; + let (variable, _) = which.vars(); + // `spec_from` checks the variable is set and + // then leaves the credential `None`, so the key + // travels one path and never sits in a struct + // longer than it must — its own doc's rule. + match io_cli::provider::spec_from( + which, + std::env::var(variable).ok(), + Some(model.clone()), + ) { + Err(why) => Some(why), + Ok(spec) => io_cli::verify::credential(&spec) + .await + .err() + .map(|why| { + format!( + "{preset} refused the credential in \ + ${variable}: {why}" + ) + }), + } + } else { + None + }; + if let Some(why) = refused { + app.record( + Tone::Refused, + format!("{why}. Nothing was written."), + ); + picker = None; + paint(screen, &mut app)?; + continue; + } + let (scope, edit) = match at { + // A change to a link that already exists goes + // into the file that already carries it. + Some(at) => ( + at.scope, + io_cli::providers::edit(at, "model", model), + ), + // A new link goes to the user scope and is + // written with the credential shape that needs + // no secret in the file: no `api_key` for a + // vendor kind, `${env:…}` for a compatible one. + // `Key::written` owns that distinction. + None => ( + io_harness::config::Scope::User, + Some(io_cli::providers::add( + endpoint, + model, + io_cli::providers::Key::Environment + .written(endpoint) + .as_deref(), + )), + ), + }; + match edit { + None => app.record( + Tone::Refused, + "that link is no longer where it was; nothing was written" + .to_string(), + ), + Some(edit) => { + match io_cli::configure::write(&root, scope, &[edit]) { + Err(refusal) => app.record(Tone::Refused, refusal), + Ok(()) => { + match io_cli::configure::reload(&root) { + Ok((fresh, _)) => config = fresh, + Err(error) => { + app.record(Tone::Error, error) + } + } + // **Where it landed in the chain, + // said before it matters.** A new + // link goes last, so it is a + // fallback and not the provider in + // force — an operator who added one + // expecting it to answer the next + // turn needs to be told it will not. + let place = + io_cli::providers::chain(&config).len(); + app.record( + Tone::Success, + match at { + Some(_) => format!( + "{preset} now asks {model}, from the \ + next turn" + ), + None => format!( + "{preset} · {model} is link {place} \ + in the chain{}. Its credential stays \ + in the environment; nothing was \ + written into a file.", + if place == 1 { + ", so it is the provider in force" + } else { + ", so it is a fallback — promote \ + it to make it the one in force" + } + ), + }, + ); + } + } + } + } + } + } + Pick::ProviderVerb { + label, + at, + first, + kind, + model_at, + remove_at, + } => { let promote = if *first { usize::MAX } else { 1 }; - let remove = if *first { 1 } else { 2 }; + let remove = *remove_at; + // The model row descends rather than writing, so it is + // taken before the edit is worked out. + if index == *model_at { + app.record( + Tone::Muted, + "reading the model catalogue…".to_string(), + ); + paint(screen, &mut app)?; + let models: Vec = io_cli::verify::served(None) + .await + .into_iter() + .map(|model| model.id) + .collect(); + if models.is_empty() { + app.record( + Tone::Muted, + format!( + "no catalogue was served, so there is no list to \ + choose {label}'s model from" + ), + ); + } else { + descended = Some(( + Picker::new( + format!("Which model for {label}?"), + std::iter::once(Row::new("leave it".to_string())) + .chain( + models.iter().map(|m| Row::new(m.clone())), + ) + .collect(), + ), + Pick::ProviderModel { + preset: kind.clone(), + models, + at: Some(*at), + }, + )); + } + } let edit = if index == promote { io_cli::providers::promote(at) } else if index == remove { @@ -3434,16 +3763,25 @@ async fn loop_over Result>( Action::Provider => { let chain = io_cli::providers::chain(&config); if chain.is_empty() { - app.record(Tone::Muted, "no provider is configured; run `io setup`"); - } else { - picker = Some(( - Picker::new( - "Providers, in the order they are tried", - io_cli::providers::rows(&chain), - ), - Pick::Provider, - )); + // Still said, and no longer the whole answer: the add row + // below is what this release adds, and a surface that + // refused to open for the operator with nothing configured + // would be refusing exactly the operator the verb is for. + app.record(Tone::Muted, "no provider is configured yet"); } + let mut rows = io_cli::providers::rows(&chain); + // Taken before the row is pushed. See `Pick::Providers`. + let add_at = rows.len(); + rows.push(Row::with_detail( + "add a link".to_string(), + "chooses a preset, verifies its credential, and takes the model from the \ + catalogue that verification returns" + .to_string(), + )); + picker = Some(( + Picker::new("Providers, in the order they are tried", rows), + Pick::Provider { add_at }, + )); } Action::Profile => { let names = io_cli::configure::profiles(&config); @@ -6453,6 +6791,22 @@ enum Pick { label: String, at: io_cli::providers::At, first: bool, + /// The entry's `kind`, carried so the model picker can name the provider + /// it is reading a catalogue for. Read off the row the operator chose + /// rather than re-derived, which is the same rule `at` follows. + kind: String, + /// Where the "change the model" and "remove" rows sit, recorded by the + /// code that built them. + /// + /// **`promote`'s index is still worked out and these two are not**, and the + /// asymmetry is deliberate: `promote` is present or absent on one flag the + /// arm already has, while these move with every row added above them. The + /// original comment here made the argument for computing indices from the + /// flag the rows were built from; recording them is the same argument + /// carried one step further, and it is what stopped this arm removing the + /// link it had offered to re-model. + model_at: usize, + remove_at: usize, }, /// A confirmation over one session's removal. Row 0 is `store::LEAVE_IT` and /// every other row acts — the shape `/mcp` remove established and the one @@ -6551,7 +6905,35 @@ enum Pick { }, /// The provider chain, in the order `providers::rows` drew it — which is /// the order a turn tries it. - Provider, + /// + /// `add_at` is where the add row sits, taken from the length of the chain's + /// own rows before it was pushed. The rule every surface in this release + /// follows: an index worked out afterwards is an index addressing a different + /// list than the one on screen. + Provider { + add_at: usize, + }, + /// The presets whose own credential variable is already set in this shell, + /// with "leave it" at row 0. + /// + /// **Only those, deliberately.** A credential that has to be typed already has + /// a flow — `io setup` types it, verifies it and writes it — and this + /// release's `preferred_tools` forbids a second one by name. Offering a preset + /// whose variable is unset would either build that second flow or write an + /// entry that cannot authenticate. + ProviderPreset(Vec), + /// The models the verification call returned, with "leave it" at row 0. + /// + /// `at` is `None` for an add and `Some` for a change to an existing link, so + /// one picker serves both and the two cannot drift about what a chosen model + /// means. It is an `At` and never a row number: under a profile the rows and + /// the file's array describe different entries, which is the 0.21.0 defect + /// `providers::At::of` exists to make unspellable. + ProviderModel { + preset: String, + models: Vec, + at: Option, + }, /// Every skill, in the order `skillview::rows` drew them, carried as the /// `(name, path)` each row stood for. /// diff --git a/tests/providers.rs b/tests/providers.rs index 8d8734b..cf949ff 100644 --- a/tests/providers.rs +++ b/tests/providers.rs @@ -894,3 +894,50 @@ fn f10_the_default_offered_is_the_variable_the_endpoint_already_reads() { assert!(providers::variable_is_set(var)); std::env::remove_var(var); } + +/// F10's ordering, asserted against the driver as text. +/// +/// **The one gate that has to read `src/main.rs`, and it reads it because nothing +/// under `tests/` can link it.** The library never writes — it builds an `Edit` — +/// so "the verification call is made before any edit" is a property of the driver +/// alone, and a weak instrument aimed at it is the only instrument there is. It is +/// the shape `tests/context_share.rs` and `tests/contract.rs` already use. +/// +/// Sabotage: move the `configure::write` above the `verify::credential` in the +/// `Pick::ProviderModel` arm — under which this fails, and the product it +/// describes leaves a rejected credential in the operator's file. +#[test] +fn f10_the_driver_verifies_before_it_writes() { + // Line endings normalised: a Windows checkout has `\r\n`, and a gate that + // sliced on `"\n"` matched nothing and panicked on a green product in 0.19.0 + // and again in 0.23.0. + let driver = std::fs::read_to_string("src/main.rs") + .expect("the driver is beside the tests") + .replace("\r\n", "\n"); + let arm = driver + .find("Pick::ProviderModel { preset, models, at } =>") + .expect("the model arm is where the add is completed"); + let rest = &driver[arm..]; + // The arm ends where the next one begins. + let end = rest + .find("Pick::ProviderVerb {") + .expect("ProviderVerb follows ProviderModel"); + let arm = &rest[..end]; + + let verified = arm + .find("verify::credential") + .expect("the add must verify the credential at all"); + let written = arm + .find("configure::write") + .expect("the add must write something"); + assert!( + verified < written, + "the driver writes before it verifies, so a rejected credential would be left in the \ + operator's file" + ); + // And the rejection path must not fall through into the write. + assert!( + arm.contains("Nothing was written."), + "a rejected credential must say the file is unchanged" + ); +} From fe1768740c186e27e3ea2c32662956094eacc4e7 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 10:33:57 +0530 Subject: [PATCH 08/15] =?UTF-8?q?chore(develop):=20`src/manage.rs`=20?= =?UTF-8?q?=E2=80=94=20the=20one=20parse=20both=20the=20slash=20form=20and?= =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.28.0-T11 --- src/manage.rs | 1125 +++++++++++++++++++++++++++++++++++++++++++++++ tests/manage.rs | 637 +++++++++++++++++++++++++++ 2 files changed, 1762 insertions(+) create mode 100644 src/manage.rs create mode 100644 tests/manage.rs diff --git a/src/manage.rs b/src/manage.rs new file mode 100644 index 0000000..f28cf46 --- /dev/null +++ b/src/manage.rs @@ -0,0 +1,1125 @@ +//! One parse for the managed surfaces, shared by the slash form and the argv one. +//! +//! `/mcp add …` typed into a composer and `io mcp add …` typed at a shell are the +//! same sentence arriving through two doors, and this module is the only room +//! behind both of them. Every verb this release adds — `mcp add|list|get|edit| +//! remove`, `plugin add|list|remove`, `config get|set|unset|list` — is turned into +//! a [`Request`] here and into [`crate::edit::Edit`]s by [`plan`], and neither +//! entry point is allowed a second reading of the same words. +//! +//! # Why the parse is not clap's +//! +//! `src/cli.rs` is clap and stays clap: it describes `io`'s own arguments, it +//! renders `--help`, and it is the right tool for a process's argv. It cannot be +//! the tool for the slash form, because the slash form is not argv — it is a line +//! of text a person typed into a composer, and it reaches the driver as a +//! `String` with no process boundary anywhere near it. A clap-only parse would +//! therefore serve exactly one of the two doors, and the other would grow a +//! hand-written reading of the same grammar beside it. Two readings of one +//! grammar do not stay equal: the first flag added to one of them is the release +//! where `io mcp add` and `/mcp add` write different bytes, and an operator +//! discovers it by diffing a file they did not expect to differ. +//! +//! So this module parses a `&[String]` — the shape both doors can produce — and +//! clap's job on the io side shrinks to *handing its tokens through untouched* +//! (`trailing_var_arg` and `allow_hyphen_values` on the subcommand that collects +//! them). The slash side reaches the same shape through [`tokens`]. +//! +//! # Why it is in the library and not in the driver +//! +//! Nothing under `tests/` links `src/main.rs`. A parse written there is a parse no +//! integration test can call and no sabotage can make fail — the same reasoning +//! that put `configure::refusal` and plain-mode resolution in the library rather +//! than beside their call sites. The acceptance criterion this module exists for +//! compares the **bytes two entry paths write**, and a comparison that cannot be +//! run is a criterion that is asserted by prose. +//! +//! # `--` ends io's arguments, and everything after it is opaque +//! +//! `io mcp add semlith -- semlith --store /path/to/.semlith mcp` declares a server +//! whose command is `semlith` and whose arguments are `--store`, `/path/to/.semlith` +//! and `mcp`, **verbatim**. The scan stops at the first `--` and copies the rest, +//! so a `--plain` written there is the server's flag and never io's: a parser that +//! kept looking for its own flags past that point would silently eat an argument +//! out of the middle of somebody's command line and start a server that behaves +//! differently from the one they wrote down. +//! +//! # The transport is decided by the form, and a disagreement is refused +//! +//! A URL means HTTP and a command after `--` means stdio; that is the whole rule, +//! and [`McpTransport`] is constructed in exactly one place below so that it stays +//! the whole rule. `--transport` is accepted because another harness's users type +//! it and their muscle memory is not a thing to punish, but it is read as an +//! **assertion about the form**, checked against it, and refused by name when the +//! two disagree. It is never a tie-break: resolving `--transport stdio --url …` by +//! precedence would silently discard half of what the operator wrote, and which +//! half depends on a rule nobody can see. +//! +//! That is also why the foreign ordering — `mcp add --transport http linear-server +//! https://…/mcp` — has no branch of its own. A second positional **is** a URL, +//! wherever `--transport` sits, so the two orderings differ only in where the URL +//! was found and converge on one [`McpServer`] before a single byte is written. +//! A branch keyed on the flag would be two constructions of one server, and the +//! ordering that got less attention would be the one that writes a different file. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use io_harness::config::Scope; +use io_harness::{McpServer, McpTransport}; + +use crate::configure::Kind; +use crate::edit::Edit; + +/// One sentence an operator typed, on whichever surface they typed it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Request { + /// The MCP servers. + Mcp(McpVerb), + /// The capability bundles. + Plugin(PluginVerb), + /// The settings. + Config(ConfigVerb), +} + +/// What `/mcp` and `io mcp` can be asked to do. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum McpVerb { + /// Declare a whole server. `scope` is where it is written — the operator's + /// own file unless `--scope` said otherwise. + Add { server: McpServer, scope: Scope }, + /// Every configured server. + List, + /// One configured server, whole. + Get { id: String }, + /// Change one key of one entry. `value` is **TOML source**, the way + /// [`crate::servers::edit`] takes it, and it was rendered here rather than at + /// the call site so that a command with a quote in it cannot become a file + /// that no longer parses. + Edit { + id: String, + key: String, + value: String, + }, + /// Take one entry away, whole. + Remove { id: String }, +} + +/// What `/plugin` and `io plugin` can be asked to do. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginVerb { + /// Declare a bundle directory. + Add { path: PathBuf, scope: Scope }, + /// Every declared bundle, loaded and refused. + List, + /// Undeclare a bundle. No scope: the file that named it is the file the + /// removal has to go to, and [`plan`] finds it. + Remove { path: PathBuf }, +} + +/// What `/config` and `io config` can be asked to do. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigVerb { + /// One key's value and what decided it. + Get { key: String }, + /// Write one key. `value` is TOML source, already checked against the key's + /// [`Kind`]: a choice arrives quoted, a number and a flag bare, so that what + /// is written is a value io-harness reads back as the one that was typed. + Set { + key: String, + value: String, + /// `None` where `--scope` was not given, which means *inherit the file + /// already deciding this key*. See `Args::scope_or_inherited`. + scope: Option, + }, + /// Delete one key's line, so the layer below it decides again. + Unset { + key: String, + scope: Option, + }, + /// Every key this surface offers. + List, +} + +/// The edits a request comes to, and the one file they go in. +/// +/// A `Vec` because [`crate::configure::write`] takes a slice and because a future +/// verb may need two edits in one round trip; every verb here produces exactly +/// one, and that is a property worth keeping rather than a shape worth widening. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Plan { + /// The scope whose file is written. + pub scope: Scope, + /// What to write, applied together or not at all. + pub edits: Vec, +} + +/// The tokens of a slash line, as a shell would have handed them to `io`. +/// +/// The leading `/` goes, so `/mcp add x` and `mcp add x` are one token slice, and +/// quoted runs survive as single tokens with their quotes removed — because that +/// is what the shell does to `io config set app.io-cli.gates.contains "all green"` +/// before `io` ever sees it. A slash surface that kept the quotes would write +/// `"\"all green\""` into the file: the same words, a different value, and a +/// difference that only shows up in the byte comparison this module exists to +/// pass. +/// +/// ponytail: quotes and nothing else — no backslash escapes, no `$` expansion, no +/// globbing. A composer line is not a shell and the missing pieces are the ones +/// that would make it pretend to be one; an operator who needs them has the shell +/// itself, where `io mcp add …` reaches this exact parse. +#[must_use] +pub fn tokens(line: &str) -> Vec { + let line = line.trim(); + let line = line.strip_prefix('/').unwrap_or(line); + let mut out: Vec = Vec::new(); + let mut word = String::new(); + // Set by an opening quote, so that `""` is a deliberate empty value rather + // than a token that vanishes on its way to a parse that would then report a + // missing argument for something the operator did write. + let mut quoted = false; + let mut open: Option = None; + for c in line.chars() { + match (open, c) { + (Some(q), c) if c == q => open = None, + (Some(_), c) => word.push(c), + (None, '"' | '\'') => { + open = Some(c); + quoted = true; + } + (None, c) if c.is_whitespace() => { + if quoted || !word.is_empty() { + out.push(std::mem::take(&mut word)); + quoted = false; + } + } + (None, c) => word.push(c), + } + } + if quoted || !word.is_empty() { + out.push(word); + } + out +} + +/// Read one line of tokens into the request it names. +/// +/// The token slice starts at the **surface** — `["mcp", "add", …]` — which is +/// exactly what `io mcp add …` leaves after the binary name and exactly what +/// [`tokens`] makes of `/mcp add …`. A leading `/` is tolerated here as well, so +/// a caller that forgot to strip one gets the same reading rather than a refusal +/// about a surface called `/mcp`. +/// +/// Every `Err` is a sentence naming what was wrong and what is accepted instead. +/// There is no bare "invalid argument" in this module: the operator is at a +/// terminal with no `--help` open, and a refusal that does not say what to type +/// next costs them a round trip to the documentation for something the parser +/// already knew. +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`" + .to_string(), + ); + }; + let surface = surface.strip_prefix('/').unwrap_or(surface.as_str()); + let verb = tokens.get(1).map(String::as_str); + let args = scan(tokens.get(2..).unwrap_or(&[]))?; + + match (surface, verb) { + ("mcp", Some("add")) => mcp_add(&args).map(Request::Mcp), + ("mcp", Some("list")) => { + args.nothing("mcp list")?; + Ok(Request::Mcp(McpVerb::List)) + } + ("mcp", Some("get")) => { + args.only("mcp get", &[])?; + Ok(Request::Mcp(McpVerb::Get { + id: args.one_word("mcp get", "the id of a configured server")?, + })) + } + ("mcp", Some("edit")) => mcp_edit(&args).map(Request::Mcp), + ("mcp", Some("remove")) => { + args.no_scope("mcp remove")?; + args.only("mcp remove", &[])?; + Ok(Request::Mcp(McpVerb::Remove { + id: args.one_word("mcp remove", "the id of a configured server")?, + })) + } + ("plugin", Some("add")) => { + args.only("plugin add", &["scope"])?; + Ok(Request::Plugin(PluginVerb::Add { + path: PathBuf::from(args.one_word("plugin add", "the directory of a bundle")?), + scope: args.scope()?, + })) + } + ("plugin", Some("list")) => { + args.nothing("plugin list")?; + Ok(Request::Plugin(PluginVerb::List)) + } + ("plugin", Some("remove")) => { + args.no_scope("plugin remove")?; + args.only("plugin remove", &[])?; + Ok(Request::Plugin(PluginVerb::Remove { + path: PathBuf::from(args.one_word("plugin remove", "the directory of a bundle")?), + })) + } + ("config", Some("get")) => { + args.only("config get", &[])?; + Ok(Request::Config(ConfigVerb::Get { + key: args.one_word("config get", "a setting's dotted key")?, + })) + } + ("config", Some("set")) => config_set(&args), + ("config", Some("unset")) => { + args.only("config unset", &["scope"])?; + Ok(Request::Config(ConfigVerb::Unset { + key: args.one_word("config unset", "a setting's dotted key")?, + scope: args.scope_or_inherited()?, + })) + } + ("config", Some("list")) => { + args.nothing("config list")?; + Ok(Request::Config(ConfigVerb::List)) + } + ("mcp" | "plugin" | "config", Some(unknown)) => Err(format!( + "`{unknown}` is not a verb `{surface}` takes; it takes {}", + verbs(surface) + )), + ("mcp" | "plugin" | "config", None) => Err(format!( + "`{surface}` needs a verb after it; it takes {}", + verbs(surface) + )), + (unknown, _) => Err(format!( + "`{unknown}` is not a surface io manages; they are `mcp`, `plugin` and `config`" + )), + } +} + +/// The verbs of one surface, for the sentence that refuses another word. +fn verbs(surface: &str) -> &'static str { + match surface { + "mcp" => "`add`, `list`, `get`, `edit` and `remove`", + "plugin" => "`add`, `list` and `remove`", + _ => "`get`, `set`, `unset` and `list`", + } +} + +/// Turn a request into the file and the edits that carry it out. +/// +/// `Ok(None)` is a **read** — `list`, `get` — which writes nothing and is +/// rendered by the caller from the `Config` it already holds. It is `None` rather +/// than an empty `Plan` because an empty edit list handed to +/// [`crate::configure::write`] would create a file, discover the whole tree and +/// report success for a question nobody asked to have answered in writing. +/// +/// **Every write is one of the edits this crate already had**, and that is the +/// constraint rather than an observation: `servers::add`, `servers::edit`, +/// `servers::remove`, `pluginview::add`, `pluginview::remove`, `Edit::set` and +/// `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. +/// +/// 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 +/// `--scope` there is refused at parse rather than honoured, because honouring it +/// would aim an index counted in one file's array at a different file's. +/// Which file a `config` write lands in: the one `--scope` named, or the one +/// already deciding the key. +/// +/// **The same answer `/config`'s descent gives**, through the same function, so +/// the two entry paths cannot disagree about where a key lives. A configuration +/// that cannot be discovered at all is not a reason to refuse the write — the +/// caller's own `configure::write` reports that far better than a guess here +/// would — so it falls back to the user scope, which is where a key nothing +/// decides goes anyway. +fn decided_scope(root: &Path, key: &str, asked: Option) -> Scope { + if let Some(scope) = asked { + return scope; + } + io_harness::config::Config::discover(root) + .map(|config| crate::configure::destination(&config, key).0) + .unwrap_or(Scope::User) +} + +pub fn plan(root: &Path, request: &Request) -> Result, String> { + let plan = match request { + Request::Mcp(McpVerb::Add { server, scope }) => Plan { + scope: *scope, + edits: vec![crate::servers::add(server)], + }, + Request::Mcp(McpVerb::Edit { id, key, value }) => { + let at = declared_server(root, id)?; + let edit = crate::servers::edit(&at, key, value).ok_or_else(|| { + format!( + "`{key}` is not a key an `[[mcp]]` entry carries, and io-harness would accept \ + it into the file and ignore it; the keys are {}", + crate::servers::KEYS + .iter() + .map(|known| format!("`{known}`")) + .collect::>() + .join(", ") + ) + })?; + Plan { + scope: at.scope, + edits: vec![edit], + } + } + Request::Mcp(McpVerb::Remove { id }) => { + let at = declared_server(root, id)?; + Plan { + scope: at.scope, + edits: vec![crate::servers::remove(&at)], + } + } + Request::Plugin(PluginVerb::Add { path, scope }) => { + let dir = resolve(root, path); + if let Some(refusal) = crate::pluginview::refusal(&dir) { + return Err(refusal); + } + Plan { + scope: *scope, + edits: vec![crate::pluginview::add(&crate::pluginview::declared( + root, &dir, + ))], + } + } + Request::Plugin(PluginVerb::Remove { path }) => { + let dir = resolve(root, path); + let (scope, index) = crate::pluginview::declared_at(root, &dir).ok_or_else(|| { + format!( + "no configuration file declares {}, so there is no `[[plugin]]` entry to \ + remove; `plugin list` shows what is declared", + dir.display() + ) + })?; + Plan { + scope, + edits: vec![crate::pluginview::remove(index)], + } + } + Request::Config(ConfigVerb::Set { key, value, scope }) => Plan { + scope: decided_scope(root, key, *scope), + edits: vec![Edit::set(key.clone(), value.clone())], + }, + Request::Config(ConfigVerb::Unset { key, scope }) => Plan { + scope: decided_scope(root, key, *scope), + // `unset` and not `remove`, and the two are not interchangeable: + // `remove` takes a whole `[section]` or `[[array]]` entry away and + // cannot name a key at all, so asked for `run.max_steps` it would + // look for a `[run.max_steps]` header, find none, and refuse — or, + // 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())], + }, + Request::Mcp(McpVerb::List | McpVerb::Get { .. }) + | Request::Plugin(PluginVerb::List) + | Request::Config(ConfigVerb::Get { .. } | ConfigVerb::List) => return Ok(None), + }; + Ok(Some(plan)) +} + +/// Where the deciding file declares the server called `id`. +/// +/// The refusal names the id and the verb that lists them, because "not found" over +/// a set of three configuration files tells an operator nothing about which of +/// them to open. +fn declared_server(root: &Path, id: &str) -> Result { + crate::servers::declared_in(root, id).ok_or_else(|| { + format!( + "no configuration file in force declares an MCP server called `{id}`, so there is \ + nothing to change; `mcp list` shows the ones that are configured" + ) + }) +} + +/// A typed path, against the discovery root when it is relative. +fn resolve(root: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + } +} + +// --- the scan ----------------------------------------------------------------- + +/// One line's words, sorted into the three things a word can be. +/// +/// Deliberately not a per-verb struct: every verb reads the same three piles, and +/// a scanner per verb is how the two doors would come to disagree about what a +/// `--` means. +#[derive(Debug, Default)] +struct Args { + /// Words that are not flags and are not past the `--`. + positional: Vec, + /// `(name, value)` in the order they were written, repeats kept — `--env` and + /// `--header` are repeatable and a map here would silently keep the last. + flags: Vec<(String, String)>, + /// Everything after the first `--`, verbatim. `Some(vec![])` is a `--` with + /// nothing after it, which is a different mistake from no `--` at all. + opaque: Option>, +} + +/// Sort a verb's words into [`Args`]. +/// +/// **The scan stops dead at the first `--`.** Nothing after it is examined, so a +/// server's own `--plain`, `--json` or `--scope` is copied through as text and +/// cannot be read as io's. +fn scan(tokens: &[String]) -> Result { + let mut args = Args::default(); + let mut i = 0; + while i < tokens.len() { + let token = &tokens[i]; + if token == "--" { + args.opaque = Some(tokens[i + 1..].to_vec()); + break; + } + let Some(name) = token.strip_prefix("--") else { + // A single dash followed by a LETTER is somebody reaching for a short + // flag io does not have, and saying so is better than writing their + // `-v` into a file as a server's name. A single dash followed by + // anything else is a value: `app.io-cli.gates.expect_exit` is the one + // signed setting in the catalogue and `-1` is a legitimate value for + // it, so a rule that refused every leading dash would refuse the only + // key that needs one. + if token.starts_with('-') && token.chars().nth(1).is_some_and(char::is_alphabetic) { + return Err(format!( + "`{token}` is not one of io's own flags — they are all spelled with two \ + dashes — and a flag meant for the server itself belongs after `--`" + )); + } + args.positional.push(token.clone()); + i += 1; + continue; + }; + // `--name=value` as well as `--name value`, because the first is what a + // shell user types and the second is what a composer user types, and a + // parse that took only one of them would be a parse that works on one + // surface. + let (name, inline) = match name.split_once('=') { + Some((name, value)) => (name, Some(value.to_string())), + None => (name, None), + }; + if name.is_empty() { + return Err(format!( + "`{token}` names no flag; io's flags are spelled `--name value` or `--name=value`" + )); + } + let value = match inline { + Some(value) => { + i += 1; + value + } + None => { + let Some(value) = tokens.get(i + 1) else { + return Err(format!( + "`--{name}` needs a value after it and the line ends there" + )); + }; + i += 2; + value.clone() + } + }; + args.flags.push((name.to_string(), value)); + } + Ok(args) +} + +impl Args { + /// The one value of `name`, or `None`. + /// + /// Repetition is refused rather than resolved: `--url a --url b` is two + /// different servers written down, and picking either is picking for someone + /// who has already told you they are not sure. + fn one(&self, name: &str) -> Result, String> { + let mut found = self.flags.iter().filter(|(flag, _)| flag == name); + let first = found.next().map(|(_, value)| value.as_str()); + if found.next().is_some() { + return Err(format!( + "`--{name}` was given more than once and it takes a single value; nothing was \ + written" + )); + } + Ok(first) + } + + /// Every value of `name`, in the order written. For the repeatable pair. + fn all(&self, name: &str) -> Vec<&str> { + self.flags + .iter() + .filter(|(flag, _)| flag == name) + .map(|(_, value)| value.as_str()) + .collect() + } + + /// Refuse any flag `verb` does not take, by name. + fn only(&self, verb: &str, known: &[&str]) -> Result<(), String> { + for (name, _) in &self.flags { + if !known.contains(&name.as_str()) { + return Err(format!( + "`--{name}` is not a flag `{verb}` takes; it takes {}", + named(known) + )); + } + } + Ok(()) + } + + /// Refuse a `--` section on a verb that has no command to carry. + fn no_command(&self, verb: &str) -> Result<(), String> { + match &self.opaque { + None => Ok(()), + Some(rest) => Err(format!( + "`{verb}` takes no command, so the `--` and everything after it ({}) has nowhere \ + to go; a server's command is written when it is added", + rest.join(" ") + )), + } + } + + /// Refuse `--scope` where the scope is not the operator's to choose. + fn no_scope(&self, verb: &str) -> Result<(), String> { + if self.all("scope").is_empty() { + return Ok(()); + } + Err(format!( + "`{verb}` takes no `--scope`: the change goes to the file that declares the entry, \ + which io finds by name — a scope chosen here would aim a position counted in one \ + file's array at another file's" + )) + } + + /// `--scope`, defaulting to the operator's own file. + /// + /// [`Scope::User`] rather than the workspace, because it is the file that is + /// this person's and is not committed: a default of `Scope::Project` would put + /// one operator's server into a repository everyone else clones, which is a + /// disclosure and not a convenience. + /// The scope `--scope` named, or `None` when it was not given. + /// + /// **`None` is not "the user scope"**, and collapsing the two is F13's own + /// named sabotage. A `config set` with no `--scope` inherits the file already + /// deciding the key; answering `Scope::User` here instead would silently + /// shadow a committed project setting with a personal one, which is the change + /// an operator is least able to see afterwards. The verbs that create a new + /// entry — `mcp add`, `plugin add` — have nothing to inherit and resolve the + /// `None` to the user scope themselves. + fn scope_or_inherited(&self) -> Result, String> { + Ok(match self.one("scope")? { + None => None, + Some("user") => Some(Scope::User), + Some("project") => Some(Scope::Project), + Some("local") => Some(Scope::Local), + Some(other) => { + return Err(format!( + "`{other}` is not a scope; they are `user` (your own file), `project` (the \ + committed one) and `local` (this checkout only)" + )) + } + }) + } + + fn scope(&self) -> Result { + Ok(match self.one("scope")? { + None => Scope::User, + Some("user") => Scope::User, + Some("project") => Scope::Project, + Some("local") => Scope::Local, + Some(other) => { + return Err(format!( + "`{other}` is not a scope; they are `user` (your own file), `project` (the \ + committed one) and `local` (this checkout only)" + )) + } + }) + } + + /// Exactly one positional, described by what it should have been. + fn one_word(&self, verb: &str, wanted: &str) -> Result { + self.no_command(verb)?; + match self.positional.len() { + 1 => Ok(self.positional[0].clone()), + 0 => Err(format!("`{verb}` needs {wanted} after it")), + _ => Err(format!( + "`{verb}` takes {wanted} and nothing else, and {} words were given ({}); quote a \ + value that contains a space", + self.positional.len(), + self.positional.join(" ") + )), + } + } + + /// No positionals, no flags, no command. For the two listing verbs. + fn nothing(&self, verb: &str) -> Result<(), String> { + self.no_command(verb)?; + self.only(verb, &[])?; + if self.positional.is_empty() { + return Ok(()); + } + Err(format!( + "`{verb}` lists everything and takes no arguments, so `{}` was not understood", + self.positional.join(" ") + )) + } +} + +/// A flag list for a refusal, or the sentence for a verb that takes none. +fn named(flags: &[&str]) -> String { + if flags.is_empty() { + return "no flags at all".to_string(); + } + flags + .iter() + .map(|flag| format!("`--{flag}`")) + .collect::>() + .join(", ") +} + +// --- mcp ---------------------------------------------------------------------- + +/// The flags `mcp add` takes. Named once, so the refusal and the reader agree. +const ADD_FLAGS: &[&str] = &["url", "transport", "env", "header", "timeout-secs", "scope"]; + +/// `mcp add (--url | -- [args…]) [flags]`. +/// +/// The two shapes an operator may have learned elsewhere both arrive here and +/// neither gets a branch of its own — see the module docs. What varies between +/// them is one thing, `url`, and where it was read from; everything below that +/// line is the same code for both. +fn mcp_add(args: &Args) -> Result { + args.only("mcp add", ADD_FLAGS)?; + let scope = args.scope()?; + + let Some(id) = args.positional.first().cloned() else { + return Err( + "`mcp add` needs a name for the server, then either `--url ` for an HTTP \ + server or `-- [args…]` for one io starts itself" + .to_string(), + ); + }; + // A second positional IS a URL, whatever `--transport` says and wherever it + // sits. That is what makes `mcp add --transport http linear https://…/mcp` + // and `mcp add linear --url https://…/mcp` one reading rather than two: the + // flag never selects a parse, it only asserts a claim that is checked below. + let positional_url = match args.positional.get(1) { + None => None, + Some(url) if url.starts_with("http://") || url.starts_with("https://") => Some(url.clone()), + Some(other) => { + return Err(format!( + "a second word after the server's name is read as its URL, and `{other}` is not \ + one; the command of a server io starts itself goes after `--`, as in `mcp add \ + {id} -- {other} …`" + )) + } + }; + if args.positional.len() > 2 { + return Err(format!( + "`mcp add` takes a name and at most a URL, and {} words were given ({}); a command \ + and its arguments go after `--`", + args.positional.len(), + args.positional.join(" ") + )); + } + let url = match (args.one("url")?, positional_url) { + (Some(flag), Some(positional)) if flag != positional => { + return Err(format!( + "two different URLs were given — `--url {flag}` and `{positional}` — and io will \ + not choose between them" + )) + } + (Some(flag), _) => Some(flag.to_string()), + (None, positional) => positional, + }; + + // The claim, checked against the form rather than used to pick one. Both arms + // name the flag AND the form it contradicts, because the operator has to know + // which of the two things they wrote is the one to delete. + match args.one("transport")? { + Some("stdio") if url.is_some() => { + return Err( + "`--transport stdio` was given together with a URL, and a stdio server \ + has no URL: io starts it as a command. Drop the `--transport stdio` for \ + an HTTP server, or write the command after `--` instead of the URL" + .to_string(), + ) + } + Some("http") if args.opaque.is_some() => { + return Err( + "`--transport http` was given together with a command after `--`, and an \ + HTTP server is not started by io: it is dialled. Drop the `--transport \ + http` for a server io starts, or give `--url ` instead of the \ + command" + .to_string(), + ) + } + Some("http" | "stdio") | None => {} + Some(other) => { + return Err(format!( + "`--transport {other}` is not a transport; they are `http`, which is written \ + `--url `, and `stdio`, which is written `-- [args…]`" + )) + } + } + + let env = pairs(&args.all("env"), "env", "KEY=VALUE")?; + let headers = pairs(&args.all("header"), "header", "NAME=VALUE")?; + + // The one construction site for `McpTransport` in this module. Two orderings, + // one server, and a change to how a server is built cannot reach one door + // without reaching the other. + let transport = match (url, &args.opaque) { + (Some(_), Some(_)) => { + return Err( + "both a URL and a command after `--` were given, and a server is reached \ + one way or the other; keep the URL for a server io dials, or the command \ + for one io starts" + .to_string(), + ) + } + (None, None) => { + return Err(format!( + "`mcp add {id}` says nothing about how to reach the server; give `--url ` \ + for one io dials over HTTP, or `-- [args…]` for one io starts itself" + )) + } + (Some(url), None) => { + if !env.is_empty() { + return Err(format!( + "`--env` sets the environment of a process io starts, and `{id}` is an HTTP \ + server io only dials; a value an HTTP server needs is sent as `--header \ + NAME=VALUE`" + )); + } + McpTransport::Http { url, headers } + } + (None, Some(rest)) => { + let Some((command, arguments)) = rest.split_first() else { + return Err(format!( + "nothing follows the `--`, so there is no command to start `{id}` with; the \ + shape is `mcp add {id} -- [args…]`" + )); + }; + if !headers.is_empty() { + return Err(format!( + "`--header` is sent with an HTTP request, and `{id}` is a server io starts as \ + a process; something a started server needs is passed as `--env KEY=VALUE` \ + or as one of its own arguments after `--`" + )); + } + McpTransport::Stdio { + command: command.clone(), + // Verbatim, in the order written, including anything that looks + // like one of io's own flags. See the module docs. + args: arguments.to_vec(), + env, + } + } + }; + + // 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. + let mut server = McpServer { + id, + transport, + // Asked of the harness rather than written as a literal, the way + // `servers::add` asks it: the default is io-harness's, and a number + // copied into io-cli is one that goes stale in silence. + timeout_secs: McpServer::stdio("", "").timeout_secs, + }; + if let Some(seconds) = args.one("timeout-secs")? { + server.timeout_secs = timeout(seconds)?; + } + Ok(McpVerb::Add { server, scope }) +} + +/// `--timeout-secs`, refusing the two values that would break a server quietly. +fn timeout(value: &str) -> Result { + let seconds: u64 = value.parse().map_err(|_| { + format!( + "`--timeout-secs {value}` is not a whole number of seconds; it is a per-call \ + timeout, as in `--timeout-secs 30`" + ) + })?; + if seconds == 0 { + return Err( + "`--timeout-secs 0` would time every call out before it was made; leave the \ + flag off for io-harness's own default" + .to_string(), + ); + } + Ok(seconds) +} + +/// `KEY=VALUE` pairs from a repeatable flag. +/// +/// Split at the **first** `=`, because a value may contain one — an `Authorization` +/// header and a base64 token both do — and a split at the last would move half the +/// value into the name. +fn pairs(given: &[&str], flag: &str, shape: &str) -> Result, String> { + let mut out = BTreeMap::new(); + for pair in given { + let Some((name, value)) = pair.split_once('=') else { + return Err(format!( + "`--{flag} {pair}` has no `=` in it; the shape is `--{flag} {shape}`" + )); + }; + if name.is_empty() { + return Err(format!( + "`--{flag} {pair}` names nothing before the `=`; the shape is `--{flag} {shape}`" + )); + } + // Last wins for a repeated name, which is the only reading with a + // meaning: the operator typed the second one after the first. + out.insert(name.to_string(), value.to_string()); + } + Ok(out) +} + +/// `mcp edit -- `, one key at a time. +/// +/// The vocabulary is `add`'s, so nothing has to be learned twice, and the three +/// keys that describe the *shape* of a server — `--transport`, `--env` and +/// `--header` — are refused by name rather than half-applied. Changing a server +/// from stdio to HTTP is not one key: it is a different set of keys, and an entry +/// that gained a `url` while keeping its `command` is one io-harness accepts and +/// reaches the wrong way. Re-adding the server writes the whole entry in one go, +/// which is what `servers::add` exists to do. +fn mcp_edit(args: &Args) -> Result { + const EDIT_FLAGS: &[&str] = &["command", "url", "timeout-secs"]; + for shaping in ["transport", "env", "header"] { + if !args.all(shaping).is_empty() { + return Err(format!( + "`--{shaping}` is part of how a server is reached rather than one key of it, and \ + an entry edited halfway into another transport is one io-harness loads and \ + cannot use; remove the server and add it again, which writes the whole entry at \ + once" + )); + } + } + args.no_scope("mcp edit")?; + args.only("mcp edit", EDIT_FLAGS)?; + + let Some(id) = args.positional.first().cloned() else { + return Err( + "`mcp edit` needs the id of a configured server, then one of `--command`, \ + `--url`, `--timeout-secs`, or `-- ` for its arguments" + .to_string(), + ); + }; + if args.positional.len() > 1 { + return Err(format!( + "`mcp edit` takes one server id and a change to make, and `{}` was given after \ + `{id}`; a new value goes behind its flag, as in `mcp edit {id} --command mcp-find`", + args.positional[1..].join(" ") + )); + } + + // Each candidate is `(key, TOML source)`, rendered here so that a command + // path with a backslash or a quote in it cannot become a file that no longer + // parses — `servers::edit` takes source, and `format!("\"{value}\"")` is the + // call site every caller reaches for and the one that breaks on Windows. + let mut changes: Vec<(String, String)> = Vec::new(); + if let Some(command) = args.one("command")? { + changes.push(("command".to_string(), crate::servers::quoted(command))); + } + if let Some(url) = args.one("url")? { + changes.push(("url".to_string(), crate::servers::quoted(url))); + } + if let Some(seconds) = args.one("timeout-secs")? { + changes.push(("timeout_secs".to_string(), timeout(seconds)?.to_string())); + } + if let Some(rest) = &args.opaque { + let items: Vec<&str> = rest.iter().map(String::as_str).collect(); + changes.push(("args".to_string(), crate::edit::array(&items))); + } + + match changes.len() { + 1 => { + let (key, value) = changes.remove(0); + Ok(McpVerb::Edit { id, key, value }) + } + 0 => Err(format!( + "`mcp edit {id}` says nothing to change; give one of `--command `, `--url \ + `, `--timeout-secs `, or `-- ` to replace the server's arguments" + )), + // One key per write, because `configure::write` splices, re-discovers and + // rolls back per call: two keys would be two round trips with a + // half-changed server on disk between them, and the second is the one + // that can be refused. + _ => Err(format!( + "`mcp edit` changes one key at a time and {} were given ({}); run it once per key", + changes.len(), + changes + .iter() + .map(|(key, _)| format!("`{key}`")) + .collect::>() + .join(", ") + )), + } +} + +// --- config ------------------------------------------------------------------- + +/// `config set [--scope]`. +fn config_set(args: &Args) -> Result { + args.no_command("config set")?; + args.only("config set", &["scope"])?; + let scope = args.scope_or_inherited()?; + + let Some(key) = args.positional.first().cloned() else { + return Err( + "`config set` needs a setting's dotted key and a value, as in `config set \ + run.max_steps 30`; `config list` shows the keys" + .to_string(), + ); + }; + let value = config_value(&key, &args.positional[1..])?; + + // Reported here rather than discovered by the round trip, because the round + // trip's refusal takes the WHOLE FILE: `refuse_widening` runs before + // deserialization, so an operator who picks one of these in a project file + // does not get a rejected setting, they get a configuration that no longer + // parses. `configure::write` would roll it back and say so in io-harness's + // words; saying it here means the file is never written at all. + // **Only for a scope the operator named.** An inherited one is not known until + // `plan` reads the configuration, so a key the *project file already decides* + // is caught there by `configure::write`'s round trip in io-harness's own + // words rather than here. Guessing at parse time would mean discovering the + // workspace from a function whose whole job is reading a line of text. + if scope == Some(Scope::Project) && crate::configure::widens_project(&key, &value) { + return Err(format!( + "io-harness refuses `{key} = {value}` in a project file, because `io.toml` is what a \ + `git clone` hands to everyone and this widens what they may do without asking them; \ + write it with `--scope local` for this checkout or `--scope user` for yourself" + )); + } + + Ok(Request::Config(ConfigVerb::Set { key, value, scope })) +} + +/// The TOML source for `words`, checked against what the key admits. +/// +/// **The kind decides the shape and never the meaning.** io-harness owns what a +/// setting does; [`crate::configure::kind_of`] answers only how a value is +/// obtained, and this turns a typed word into the source that expresses it. A key +/// the catalogue does not name is written as a string and left to +/// `configure::write`'s round trip, which is io-harness reading its own file back +/// — inventing a kind for it here would be io-cli deciding a schema it does not +/// own. +fn config_value(key: &str, words: &[String]) -> Result { + let kind = crate::configure::kind_of(key); + if matches!(kind, Some(Kind::Machine)) { + return Err(format!( + "`{key}` is written by io itself rather than typed — it dates the price table, and a \ + date set by hand is a claim about a fetch that never happened; `/config` offers the \ + refresh that writes it" + )); + } + // The one key whose value is a list (`app.io-cli.gates.command`), so the + // remaining words are the value rather than a mistake. A scalar written there + // is a value io-harness cannot read back. + if matches!(kind, Some(Kind::List)) { + if words.is_empty() { + return Err(format!( + "`{key}` is a command and its arguments, so it needs at least one word, as in \ + `config set {key} cargo test`" + )); + } + let items: Vec<&str> = words.iter().map(String::as_str).collect(); + return Ok(crate::edit::array(&items)); + } + + let word = match words { + [word] => word.as_str(), + [] => { + return Err(format!( + "`config set {key}` has no value after it; {}", + offered(key, kind.as_ref()) + )) + } + _ => { + return Err(format!( + "`{key}` takes one value and {} words were given ({}); quote a value that \ + contains a space", + words.len(), + words.join(" ") + )) + } + }; + + match kind { + Some(Kind::Flag) => match word { + "true" | "false" => Ok(word.to_string()), + other => Err(format!( + "`{other}` is not a value `{key}` takes; it is on or off, written `true` or \ + `false`" + )), + }, + Some(Kind::Choice(options)) => { + if options.iter().any(|option| option == word) { + Ok(crate::servers::quoted(word)) + } else { + Err(format!( + "`{word}` is not a value `{key}` takes; the options are {}", + options + .iter() + .map(|option| format!("`{option}`")) + .collect::>() + .join(", ") + )) + } + } + Some(Kind::Number { signed }) => { + let number: i64 = word.parse().map_err(|_| { + format!("`{word}` is not a whole number, and `{key}` counts in whole numbers") + })?; + if !signed && number < 0 { + return Err(format!( + "`{key}` counts something and cannot be negative; `{word}` was not written" + )); + } + // Re-rendered from the parsed number rather than passed through, so + // that a form TOML would reject (`+5`, a leading zero run) becomes + // the value it meant instead of a refused write. + Ok(number.to_string()) + } + // A model name, a path, free text, and a key no catalogue entry names. + // All four are strings in the file, and all four are escaped rather than + // wrapped in quotes by hand. + Some(Kind::Model | Kind::File | Kind::Text) | None => Ok(crate::servers::quoted(word)), + // Both are answered above, before a single word was demanded, so this arm + // is unreachable — and it is written as a refusal rather than left to a + // wildcard or a `panic!`. A wildcard would swallow a `Kind` variant a + // later release adds and write it as a string; a panic would end the + // process on something an operator typed. `Kind` is io-cli's own enum, so + // the exhaustive match is a compile-time gate this crate can keep. + Some(Kind::List | Kind::Machine) => Err(format!( + "`{key}` was read as two different kinds in one parse, which is a defect in io \ + rather than in what you typed; nothing was written" + )), + } +} + +/// What to say a key admits, for the refusal that has no value to name. +fn offered(key: &str, kind: Option<&Kind>) -> String { + match kind { + Some(Kind::Flag) => "it is on or off, written `true` or `false`".to_string(), + Some(Kind::Choice(options)) => format!( + "the options are {}", + options + .iter() + .map(|option| format!("`{option}`")) + .collect::>() + .join(", ") + ), + Some(Kind::Number { .. }) => "it takes a whole number".to_string(), + _ => format!("`config get {key}` shows what it is set to now"), + } +} diff --git a/tests/manage.rs b/tests/manage.rs new file mode 100644 index 0000000..0ad7a7e --- /dev/null +++ b/tests/manage.rs @@ -0,0 +1,637 @@ +//! F6, F7 and F8 — the one parse both entry paths go through, what `--` ends, +//! and the transport a form decides. +//! +//! Every test here drives `io_cli::manage` the way both doors drive it: a token +//! slice starting at the surface word. `io mcp add …` leaves exactly that after +//! the binary name, and `manage::tokens` makes exactly that of `/mcp add …`, so a +//! test written against one is a test of both — which is the property the module +//! exists to have and the reason the parse is in the library rather than in +//! `src/main.rs`, which nothing under `tests/` can link. + +use std::path::Path; + +use io_cli::manage::{self, ConfigVerb, McpVerb, PluginVerb, Request}; +use io_harness::config::Scope; +use io_harness::McpTransport; + +/// The token slice a shell hands `io`, spelled the way a test can read. +fn argv(words: &[&str]) -> Vec { + words.iter().map(|word| word.to_string()).collect() +} + +/// The server a request declares, for a test that is about the transport. +fn server(request: &Request) -> &io_harness::McpServer { + match request { + Request::Mcp(McpVerb::Add { server, .. }) => server, + other => panic!("expected an `mcp add`, got {other:?}"), + } +} + +/// The bytes a request writes into an empty file. +/// +/// The whole point of F6 is that this is the comparison rather than a comparison +/// of parsed structs: two orderings could agree on every field of an `McpServer` +/// and still be written by two code paths that spell an entry differently. +fn written(root: &Path, request: &Request) -> String { + let plan = manage::plan(root, request) + .expect("the request plans") + .expect("a write, not a read"); + io_cli::edit::apply("", &plan.edits).expect("the edits apply to an empty file") +} + +// --- F7: `--` ends io's own arguments ------------------------------------------ + +#[test] +fn f7_a_command_after_the_dashes_is_taken_verbatim() { + // The line from the module docs, end to end. `--store` is not a flag io has, + // but that is not why it survives: nothing after the `--` is looked at at all. + let request = manage::parse(&argv(&[ + "mcp", + "add", + "semlith", + "--", + "semlith", + "--store", + "/path/to/.semlith", + "mcp", + ])) + .expect("the line parses"); + + match &server(&request).transport { + McpTransport::Stdio { command, args, env } => { + assert_eq!(command, "semlith"); + // Verbatim, in order, with nothing consumed and nothing reordered. + assert_eq!(args, &["--store", "/path/to/.semlith", "mcp"]); + assert!(env.is_empty()); + } + other => panic!("a command after `--` is a stdio server, got {other:?}"), + } + assert_eq!(server(&request).id, "semlith"); +} + +#[test] +fn f7_a_flag_after_the_dashes_belongs_to_the_server_and_not_to_io() { + // `--plain` IS one of io's own flags, which is what makes this the arm worth + // asserting: a scan that kept looking for its own flags past the `--` would + // swallow it, start a server missing an argument its author wrote down, and + // report success. The scan stops dead instead. + let request = manage::parse(&argv(&[ + "mcp", + "add", + "web", + "--", + "web-server", + "--plain", + "--json", + ])) + .expect("a server's own flags after `--` are not io's"); + + match &server(&request).transport { + McpTransport::Stdio { command, args, .. } => { + assert_eq!(command, "web-server"); + assert_eq!(args, &["--plain", "--json"]); + } + other => panic!("expected stdio, got {other:?}"), + } +} + +#[test] +fn f7_the_slash_form_and_the_argv_form_are_one_token_slice() { + // The two doors, meeting before the parse. The shell removes the quotes on + // its way to `io`, so the composer's tokeniser has to as well: a slash form + // that kept them would write `"\"all green\""` — the same words, a different + // value, and a difference nothing but a byte comparison would find. + let typed = manage::tokens("/config set app.io-cli.gates.contains \"all green\""); + assert_eq!( + typed, + argv(&["config", "set", "app.io-cli.gates.contains", "all green"]) + ); + + let from_slash = manage::parse(&typed).expect("the slash form parses"); + let from_shell = manage::parse(&argv(&[ + "config", + "set", + "app.io-cli.gates.contains", + "all green", + ])) + .expect("the argv form parses"); + assert_eq!(from_slash, from_shell); +} + +// --- F8: the transport is decided by the form ---------------------------------- + +#[test] +fn f8_a_url_is_http_and_a_command_is_stdio() { + let dialled = manage::parse(&argv(&[ + "mcp", + "add", + "linear", + "--url", + "https://mcp.linear.app/mcp", + ])) + .expect("a URL is a form"); + assert!(matches!( + server(&dialled).transport, + McpTransport::Http { .. } + )); + + let started = manage::parse(&argv(&["mcp", "add", "docs", "--", "mcp-docs"])) + .expect("a command is a form"); + assert!(matches!( + server(&started).transport, + McpTransport::Stdio { .. } + )); +} + +#[test] +fn f8_a_transport_that_disagrees_with_the_form_is_refused_by_name() { + // Refused, never resolved. A precedence rule would silently discard half of + // what the operator wrote, and which half depends on a rule nobody can see — + // so the sentence names the flag AND the form it contradicts, because the + // operator has to know which of the two to delete. + let refusal = manage::parse(&argv(&[ + "mcp", + "add", + "linear", + "--transport", + "stdio", + "--url", + "https://mcp.linear.app/mcp", + ])) + .expect_err("a stdio server has no URL"); + assert!(refusal.contains("--transport stdio"), "{refusal}"); + assert!(refusal.contains("URL"), "{refusal}"); + + let other = manage::parse(&argv(&[ + "mcp", + "add", + "docs", + "--transport", + "http", + "--", + "mcp-docs", + ])) + .expect_err("an HTTP server is dialled, not started"); + assert!(other.contains("--transport http"), "{other}"); + assert!(other.contains("`--`"), "{other}"); +} + +#[test] +fn f8_neither_form_is_refused_with_both_shapes_shown() { + // The refusal an operator meets first, so it is the one that has to teach the + // grammar rather than report its violation. + let refusal = + manage::parse(&argv(&["mcp", "add", "somewhere"])).expect_err("no form was given"); + assert!(refusal.contains("--url"), "{refusal}"); + assert!(refusal.contains("--"), "{refusal}"); + assert!(refusal.contains("command"), "{refusal}"); + assert!(refusal.contains("somewhere"), "{refusal}"); +} + +#[test] +fn f6_both_accepted_orderings_write_byte_identical_configuration() { + // The criterion, and the sabotage it is aimed at: giving the foreign ordering + // its own branch. Two branches can agree on every field of an `McpServer` and + // still write an entry differently, so what is compared is the applied text. + let root = tempfile::tempdir().expect("a temporary directory"); + + let ours = manage::parse(&argv(&[ + "mcp", + "add", + "linear-server", + "--url", + "https://mcp.linear.app/mcp", + ])) + .expect("io's own ordering"); + let theirs = manage::parse(&argv(&[ + "mcp", + "add", + "--transport", + "http", + "linear-server", + "https://mcp.linear.app/mcp", + ])) + .expect("the ordering another harness teaches"); + + assert_eq!( + written(root.path(), &ours), + written(root.path(), &theirs), + "two orderings, two different files" + ); + // And the file they agree on is one io-harness reads back as the server that + // was asked for — a byte comparison of two identically wrong entries would + // pass without this. + let text = written(root.path(), &ours); + let config = io_harness::config::Config::from_toml(&text).expect("the entry loads"); + assert_eq!(config.mcp_servers().len(), 1); + assert_eq!(config.mcp_servers()[0].id, "linear-server"); + assert!(matches!( + config.mcp_servers()[0].transport, + McpTransport::Http { .. } + )); +} + +#[test] +fn f8_the_two_orderings_share_one_parse_and_one_construction() { + // The structural half of the criterion above, because a byte comparison would + // go on passing over two branches that were kept in step by hand — until the + // release where one of them gained a flag. Nothing under `tests/` can link a + // driver, so this reads the module as text, the instrument `tests/servers.rs` + // already uses on `src/main.rs`. + let source = + std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/manage.rs")) + .expect("the module"); + let flat: String = source.chars().filter(|c| !c.is_whitespace()).collect(); + + assert_eq!( + flat.matches("pubfnparse(").count(), + 1, + "there is exactly one entry point, so neither door can grow a reading of \ + its own", + ); + assert_eq!( + flat.matches("McpTransport::Http{").count(), + 1, + "an HTTP server is constructed in one place; a second is the branch the \ + foreign ordering is not allowed to have", + ); + assert_eq!( + flat.matches("McpTransport::Stdio{").count(), + 1, + "a stdio server is constructed in one place", + ); +} + +// --- the flags a transport admits ---------------------------------------------- + +#[test] +fn f8_env_on_an_http_server_and_a_header_on_a_stdio_one_are_refused_by_name() { + // Neither is a value io-harness would reject: `McpTransport` is + // `#[serde(tag = …)]` and the wrong key simply is not on the variant, so a + // parse that accepted it would drop it on the floor and start a server + // missing the credential it was given. + let no_env = manage::parse(&argv(&[ + "mcp", + "add", + "linear", + "--url", + "https://mcp.linear.app/mcp", + "--env", + "TOKEN=abc", + ])) + .expect_err("an HTTP server has no child process"); + assert!(no_env.contains("--env"), "{no_env}"); + assert!(no_env.contains("--header"), "{no_env}"); + + let no_header = manage::parse(&argv(&[ + "mcp", + "add", + "docs", + "--header", + "Authorization=Bearer x", + "--", + "mcp-docs", + ])) + .expect_err("a started server sends no headers"); + assert!(no_header.contains("--header"), "{no_header}"); + assert!(no_header.contains("--env"), "{no_header}"); +} + +#[test] +fn f7_repeated_env_and_header_accumulate() { + // Repeatable, and the sabotage is a map that keeps the last: a server given + // two variables would start with one, which no test of a single pair sees. + let started = manage::parse(&argv(&[ + "mcp", + "add", + "docs", + "--env", + "TOKEN=abc", + "--env", + "HOME=/tmp", + "--timeout-secs", + "30", + "--", + "mcp-docs", + ])) + .expect("two variables"); + match &server(&started).transport { + McpTransport::Stdio { env, .. } => { + assert_eq!(env.len(), 2); + assert_eq!(env["TOKEN"], "abc"); + assert_eq!(env["HOME"], "/tmp"); + } + other => panic!("expected stdio, got {other:?}"), + } + assert_eq!(server(&started).timeout_secs, 30); + + let dialled = manage::parse(&argv(&[ + "mcp", + "add", + "linear", + "--url", + "https://mcp.linear.app/mcp", + "--header", + "Authorization=Bearer x", + "--header", + "X-Trace=1", + ])) + .expect("two headers"); + match &server(&dialled).transport { + McpTransport::Http { headers, .. } => { + assert_eq!(headers.len(), 2); + // Split at the FIRST `=`, so a value carrying one survives whole. + assert_eq!(headers["Authorization"], "Bearer x"); + assert_eq!(headers["X-Trace"], "1"); + } + other => panic!("expected http, got {other:?}"), + } +} + +// --- config -------------------------------------------------------------------- + +#[test] +fn f6_config_set_takes_a_value_the_kind_admits_and_names_the_options_for_one_it_does_not() { + // Three kinds, because they fail differently: a flag has two words, a choice + // has a set the dependency owns, and a number has a shape. Until this release + // every key was typed blind, and a value the schema rejects reached the file. + let root = tempfile::tempdir().expect("a temporary directory"); + + for (key, value, written_as) in [ + ("sandbox.allow_network", "true", "true"), + ("sandbox.mode", "read-only", "\"read-only\""), + ("run.max_steps", "30", "30"), + ] { + let request = + manage::parse(&argv(&["config", "set", key, value])).expect("a value the kind admits"); + assert_eq!( + request, + Request::Config(ConfigVerb::Set { + key: key.to_string(), + // TOML source: a choice is a quoted string and a number and a + // flag are bare, and a parse that quoted all three would write + // `max_steps = "30"`, which io-harness cannot read back. + value: written_as.to_string(), + // `None`, not `Scope::User`: no `--scope` was given, and the file + // already deciding the key is resolved by `plan` rather than + // guessed at parse time. Collapsing the two is F13's own sabotage. + scope: None, + }) + ); + let plan = manage::plan(root.path(), &request) + .expect("it plans") + .expect("a write"); + assert_eq!(plan.scope, Scope::User); + } + + let flag = manage::parse(&argv(&["config", "set", "sandbox.allow_network", "yes"])) + .expect_err("`yes` is not a boolean"); + assert!(flag.contains("true"), "{flag}"); + assert!(flag.contains("false"), "{flag}"); + + let choice = manage::parse(&argv(&["config", "set", "sandbox.mode", "full"])) + .expect_err("`full` is not a mode"); + for option in ["read-only", "workspace-write", "full-access"] { + assert!(choice.contains(option), "{option} missing from {choice}"); + } + + let number = manage::parse(&argv(&["config", "set", "run.max_steps", "lots"])) + .expect_err("`lots` is not a number"); + assert!(number.contains("whole number"), "{number}"); + let negative = manage::parse(&argv(&["config", "set", "run.max_steps", "-1"])) + .expect_err("a step count cannot be negative"); + assert!(negative.contains("negative"), "{negative}"); +} + +#[test] +fn f6_config_unset_deletes_the_line_and_not_the_section() { + // `unset` and `remove` read as the same call at a call site and are the + // difference between clearing one setting and deleting an operator's whole + // `[run]` block. The verb is settled by which constructor was written, so + // that is what is asserted — and then proved on bytes. + const FILE: &str = "\ +[run] +max_steps = 30 +max_retries = 2 +"; + let root = tempfile::tempdir().expect("a temporary directory"); + let request = + manage::parse(&argv(&["config", "unset", "run.max_steps"])).expect("unset parses"); + let plan = manage::plan(root.path(), &request) + .expect("it plans") + .expect("a write"); + + assert_eq!(plan.edits, vec![io_cli::edit::Edit::unset("run.max_steps")]); + assert_ne!(plan.edits, vec![io_cli::edit::Edit::remove("run")]); + + let after = io_cli::edit::apply(FILE, &plan.edits).expect("the line goes"); + assert!(!after.contains("max_steps"), "{after}"); + assert!( + after.contains("[run]"), + "the section went with the key: {after}" + ); + assert!(after.contains("max_retries = 2"), "a sibling went: {after}"); +} + +#[test] +fn f4_a_widening_in_a_project_file_is_reported_rather_than_written() { + // io-harness's `refuse_widening` runs before deserialization, so this is not + // a rejected setting — it is a configuration file that no longer parses at + // all. `configure::write` would roll it back and quote the harness; saying it + // here means the file is never written. + let refusal = manage::parse(&argv(&[ + "config", + "set", + "sandbox.mode", + "full-access", + "--scope", + "project", + ])) + .expect_err("a committed file may not widen what a clone may do"); + assert!(refusal.contains("sandbox.mode"), "{refusal}"); + assert!(refusal.contains("full-access"), "{refusal}"); + assert!(refusal.contains("--scope local"), "{refusal}"); + + // The same value in a file that is not shared is the operator's own business. + let allowed = manage::parse(&argv(&[ + "config", + "set", + "sandbox.mode", + "full-access", + "--scope", + "local", + ])) + .expect("a local file may say it"); + assert_eq!( + allowed, + Request::Config(ConfigVerb::Set { + key: "sandbox.mode".to_string(), + value: "\"full-access\"".to_string(), + scope: Some(Scope::Local), + }) + ); +} + +// --- the existing writers, reached rather than re-implemented ------------------- + +#[test] +fn f6_an_edit_and_a_removal_go_to_the_file_that_declares_the_server() { + // The scope of a change to something that already exists is not the + // operator's to choose: an index counted in one file's `[[mcp]]` array aimed + // at another file's deletes a server nobody named. + let root = tempfile::tempdir().expect("a temporary directory"); + std::fs::write( + root.path().join(io_harness::config::PROJECT_FILE), + "\ +[[mcp]] +id = \"docs\" +transport = \"stdio\" +command = \"mcp-docs\" + +[[mcp]] +id = \"search\" +transport = \"stdio\" +command = \"mcp-search\" +", + ) + .expect("the fixture is written"); + + let request = manage::parse(&argv(&["mcp", "edit", "search", "--command", "mcp-find"])) + .expect("one key at a time"); + let plan = manage::plan(root.path(), &request) + .expect("the server is declared") + .expect("a write"); + assert_eq!(plan.scope, Scope::Project); + let text = std::fs::read_to_string(root.path().join(io_harness::config::PROJECT_FILE)).unwrap(); + let after = io_cli::edit::apply(&text, &plan.edits).expect("the edit applies"); + assert!(after.contains("command = \"mcp-find\""), "{after}"); + assert!( + after.contains("command = \"mcp-docs\""), + "the first entry was edited instead of the second: {after}" + ); + + // A `--scope` here is refused rather than honoured, by name. + let aimed = manage::parse(&argv(&["mcp", "remove", "search", "--scope", "user"])) + .expect_err("the scope is the file that declares it"); + assert!(aimed.contains("--scope"), "{aimed}"); + + let removal = manage::parse(&argv(&["mcp", "remove", "search"])).expect("remove parses"); + let plan = manage::plan(root.path(), &removal) + .expect("the server is declared") + .expect("a write"); + assert_eq!(plan.scope, Scope::Project); + let gone = io_cli::edit::apply(&text, &plan.edits).expect("the entry goes"); + let config = io_harness::config::Config::from_toml(&gone).expect("the file still loads"); + let left: Vec<&str> = config.mcp_servers().iter().map(|s| s.id.as_str()).collect(); + assert_eq!(left, vec!["docs"]); + + // A server no file in force declares is a refusal naming it, not a write to + // a position that was guessed. + let missing = manage::parse(&argv(&["mcp", "remove", "absent"])).expect("it parses"); + let refusal = manage::plan(root.path(), &missing).expect_err("nothing declares it"); + assert!(refusal.contains("absent"), "{refusal}"); +} + +#[test] +fn f6_a_reading_verb_plans_no_write_at_all() { + // `Ok(None)` rather than an empty plan: an empty edit list handed to + // `configure::write` creates a file, discovers the whole tree and reports + // success for a question nobody asked to have answered in writing. + let root = tempfile::tempdir().expect("a temporary directory"); + for line in [ + "mcp list", + "mcp get docs", + "plugin list", + "config list", + "config get run.max_steps", + ] { + let request = manage::parse(&manage::tokens(line)).expect(line); + assert!( + manage::plan(root.path(), &request) + .expect("a read plans") + .is_none(), + "{line} planned a write" + ); + } +} + +#[test] +fn f6_a_plugin_directory_with_no_manifest_is_refused_before_anything_is_written() { + // io-harness drops a `[[plugin]]` entry naming a directory with no manifest — + // recorded and otherwise silently absent — so an entry written without this + // check is a bundle an operator believes is loaded for a week. The refusal is + // `pluginview`'s own sentence rather than a second one written here. + let root = tempfile::tempdir().expect("a temporary directory"); + std::fs::create_dir(root.path().join("bundle")).expect("a directory that is not a bundle"); + + let request = manage::parse(&argv(&["plugin", "add", "bundle"])).expect("it parses"); + assert_eq!( + request, + Request::Plugin(PluginVerb::Add { + path: "bundle".into(), + scope: Scope::User, + }) + ); + let refusal = manage::plan(root.path(), &request).expect_err("it is not a bundle"); + assert!(refusal.contains(io_cli::pluginview::MANIFEST), "{refusal}"); + + // And with a manifest it is declared as written, relative to the root, so a + // committed entry works for everyone who clones it. + std::fs::write( + root.path() + .join("bundle") + .join(io_cli::pluginview::MANIFEST), + "name = \"demo\"\n", + ) + .expect("a manifest"); + let plan = manage::plan(root.path(), &request) + .expect("it is a bundle now") + .expect("a write"); + let after = io_cli::edit::apply("", &plan.edits).expect("the entry is written"); + assert!(after.contains("path = \"bundle\""), "{after}"); +} + +// --- refusals say what was wrong ------------------------------------------------ + +#[test] +fn every_refusal_names_what_was_wrong_and_what_is_accepted() { + // No bare "invalid argument" anywhere: the operator is at a terminal with no + // `--help` open, and a refusal that does not say what to type next costs them + // a trip to documentation for something the parser already knew. + for (line, expected) in [ + ("", "mcp"), + ("mcp", "add"), + ("mcp explode", "explode"), + ("nothing list", "nothing"), + ("mcp add web --url", "--url"), + ("mcp add web --shout loud", "--shout"), + ("mcp add web -x", "-x"), + ("mcp add web --url http://x --url http://y", "--url"), + // The commonest mistake there is: the `--` left out, so the command reads + // as a URL that is not one. The refusal shows the line it should have been. + ("mcp add web semlith", "semlith"), + ("mcp add web --transport carrier -- x", "carrier"), + ("mcp add web --timeout-secs soon -- x", "--timeout-secs"), + ("mcp add web --env TOKEN -- x", "KEY=VALUE"), + ("mcp add web --scope global -- x", "global"), + ("mcp edit docs", "--command"), + ("mcp edit docs --transport http", "--transport"), + ("mcp list extra", "extra"), + ("config set", "config list"), + ("config set prices.as_of 2026-01-01", "prices.as_of"), + ("config set run.max_steps 1 2", "quote"), + ] { + let refusal = match manage::parse(&manage::tokens(line)) { + Err(refusal) => refusal, + Ok(accepted) => panic!("`{line}` should have been refused, and became {accepted:?}"), + }; + assert!( + refusal.contains(expected), + "`{line}` was refused with `{refusal}`, which does not name `{expected}`" + ); + assert!( + refusal.len() > 30, + "`{line}` was refused with `{refusal}`, which is not a sentence" + ); + } +} From 10b1a4fa925ac728d27077c941992f75e739f134 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 10:33:57 +0530 Subject: [PATCH 09/15] =?UTF-8?q?chore(develop):=20`/mcp=20add`=20over=20b?= =?UTF-8?q?oth=20transports,=20with=20`--env`,=20`--heade=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.28.0-T06 --- src/commands.rs | 47 ++++++++++ src/main.rs | 231 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) diff --git a/src/commands.rs b/src/commands.rs index dbc9df2..2caa8de 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1190,6 +1190,15 @@ pub enum Action { Memory, /// Show the configured MCP servers and what the session has seen of them. Mcp, + /// A management verb typed as a line — `/mcp add …`, `/plugin remove …`, + /// `/config set …` — carried whole to [`crate::manage::parse`]. + /// + /// **Whole and unparsed, which is the point.** Splitting it here would be a + /// second grammar beside `manage`'s, and two grammars for one verb is exactly + /// what F6 compares bytes to rule out. The line still carries its leading `/`; + /// `manage::tokens` strips it, so the slash form and `io mcp …` cannot even + /// disagree about that. + Manage(String), /// List every skill — the five io-cli ships and the operator's own — with /// what it is for, whose it is, whether it is on, and the file it lives in. /// @@ -1898,6 +1907,27 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { // exchange for nothing, which is the rule `/status` and `/compact` // already follow. "memory" => Action::Memory, + // **A bare word opens the panel; a word with a verb after it goes to the + // one parse.** `/mcp add semlith -- semlith --store … mcp` and + // `io mcp add semlith -- …` are the same line reaching the same + // `manage::parse`, which is what makes F6's byte comparison a property of + // the code rather than of two implementations happening to agree today. + // The whole line travels, `/` and all: `manage::tokens` strips it, so the + // two doors cannot even disagree about that. + // **Only the verbs that write.** `/mcp list` in a session is the panel — + // which is a better answer than a text dump, and the one this surface has + // always given — while `io mcp list` is the listing a script reads. Two + // media for one reading, not two decisions: the write verbs, where a + // disagreement would land in an operator's file, all go through the one + // parse. + "mcp" | "servers" + if matches!( + input.split_whitespace().nth(1), + Some("add" | "edit" | "remove" | "get") + ) => + { + Action::Manage(input.to_string()) + } "mcp" | "servers" => Action::Mcp, "skills" => Action::Skills, "provider" | "providers" => Action::Provider, @@ -1932,6 +1962,11 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { // `/plugins` is admitted for the same reason `/servers` and `/providers` // are: the thing being listed is plural, so the plural is what a hand // reaches for, and refusing it teaches nothing. + "plugin" | "plugins" + if matches!(input.split_whitespace().nth(1), Some("add" | "remove")) => + { + Action::Manage(input.to_string()) + } "plugin" | "plugins" => Action::Plugin, // `/migrate` is admitted because it is the other word for this act, and // an operator arriving from another tool is by definition someone with no @@ -2021,6 +2056,18 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { None => Action::Export(Taken::Conversation(None)), } } + // **The verbs go to the one parse; the shorthand stays.** `/config set X + // Y`, `/config unset X`, `/config get X` and `/config list` are the words + // `io config` takes and reach the same `manage::parse`. `/config + // ` — no verb — is the shorthand this surface has always had and + // is what `/mcp`'s edit row and `/gates` still put in the composer; it + // keeps its own arm below rather than being rewritten into a verb the + // operator did not type. + "config" | "settings" + if matches!(input.split_whitespace().nth(1), Some("set" | "unset")) => + { + Action::Manage(input.to_string()) + } "config" | "settings" => { let mut rest = input.split_whitespace().skip(1); match rest.next() { diff --git a/src/main.rs b/src/main.rs index 416364f..c7e0ed5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -187,6 +187,30 @@ async fn run(report: &mut Vec) -> Result { return io_cli::exec::resume_main(args, config, root, cli.model).await; } + // **The three management subcommands leave by the same door, and before the + // terminal check for the same reason `io exec` does**: `io config list` in CI + // has no terminal and must not be refused for that. They open no session, + // start no run and touch no store — a configuration listing that had to build + // a contract first would be a listing nobody could put in a script. + // + // The tokens reach `manage::parse` exactly as clap received them; the parse, + // the plan and the refusals are all the library's, so this arm and the slash + // form below can only ever agree. + if let Some(words) = match &cli.command { + Some(Subcommand::Mcp(args)) => Some(("mcp", &args.words)), + Some(Subcommand::Plugin(args)) => Some(("plugin", &args.words)), + Some(Subcommand::Config(args)) => Some(("config", &args.words)), + _ => None, + } { + let (surface, rest) = words; + for line in report.drain(..) { + eprintln!("{line}"); + } + let mut tokens = vec![surface.to_string()]; + tokens.extend(rest.iter().cloned()); + return manage_main(&root, &config, &tokens); + } + // A session draws, so it needs a terminal to draw on, and saying so is better // than half-working. The check sits AFTER the subcommand is known rather than // before it, because `io exec` is the answer to a non-TTY stdout rather than a @@ -3416,6 +3440,114 @@ async fn loop_over Result>( "run `io setup` from the shell to change the configuration", ); } + // **The same parse, the same plan, the same write as `io mcp …`.** + // Nothing is decided here: the tokens, the refusals and the scope + // are all `manage`'s, and this arm reports what it returned. That + // is what makes F6's byte comparison a property of the code rather + // than of two implementations agreeing today. + Action::Manage(line) => { + let root = session.root().to_path_buf(); + let tokens = io_cli::manage::tokens(&line); + match io_cli::manage::parse(&tokens) + .and_then(|request| { + io_cli::manage::plan(&root, &request) + .map(|plan| (request, plan)) + }) { + // The refusals are finished sentences naming what was + // wrong and what is accepted, so they are printed as they + // came rather than re-worded into a second opinion about + // somebody else's rule. + Err(refusal) => app.record(Tone::Refused, refusal), + // A reading verb reaching here can only be `mcp get`: the + // list verbs are routed to their own panels before `parse` + // is asked, because a panel is a better answer in a session + // than a text dump is. Answered from the configuration this + // session is running on. + Ok((io_cli::manage::Request::Mcp(io_cli::manage::McpVerb::Get { id }), None)) => { + match io_cli::servers::servers(&config, &app.servers) + .into_iter() + .find(|server| server.id == id) + { + None => app.record( + Tone::Refused, + format!("no configuration file in force declares {id}"), + ), + Some(server) => app.record( + Tone::Muted, + format!( + "{} · {} · {}", + server.id, + server.transport, + server.decided.word() + ), + ), + } + } + Ok((_, None)) => {} + Ok((request, Some(plan))) => { + match io_cli::configure::write(&root, plan.scope, &plan.edits) { + Err(refusal) => app.record(Tone::Refused, refusal), + Ok(()) => { + match io_cli::configure::reload(&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!( + "written to the {} file, in force from the next turn", + io_cli::configure::Decided::File { + scope: plan.scope, + path: Default::default(), + } + .word() + ), + ); + // **The preflight after the write, never + // instead of it.** A server the policy will + // refuse is still written — the report is a + // disclosure, and making the file depend on + // the posture at the moment of typing is what + // F9's second sabotage arm describes. + if let io_cli::manage::Request::Mcp( + io_cli::manage::McpVerb::Add { server, .. }, + ) = &request + { + // **The merged policy, not the file's + // alone.** The posture the operator chose + // replaces the tier defaults and the + // session's remembered allowances are a + // layer over both, so a preflight 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(), + ); + let report = + io_cli::preflight::check(server, &policy); + app.record( + if report.starts() { + Tone::Muted + } else { + Tone::Warning + }, + io_cli::preflight::line(&report), + ); + } + } + } + } + } + } Action::Mcp => { let list = io_cli::servers::servers(&config, &app.servers); if list.is_empty() { @@ -7092,6 +7224,105 @@ enum Pick { /// /// Every scope is offered whether or not its file exists yet, because writing a /// key into a scope for the first time is how this is used. +/// `io mcp`, `io plugin` and `io config` — the argument forms, end to end. +/// +/// **Nothing but the answer goes to stdout.** A listing is what a script reads, so +/// prose about what happened, and the MCP policy preflight in particular, go to +/// stderr where they cannot contaminate a pipe. The exit status says whether the +/// operation *happened*: a refusal is non-zero, and a preflight that reports a +/// server the policy will not start is **zero**, because the entry was written and +/// the disclosure is not a veto. +/// +/// 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( + root: &std::path::Path, + config: &io_harness::config::Config, + tokens: &[String], +) -> Result { + let request = io_cli::manage::parse(tokens)?; + // The read verbs first: they plan no write at all, which is what + // `plan` answering `None` means. + match &request { + io_cli::manage::Request::Mcp(io_cli::manage::McpVerb::List) => { + for server in io_cli::servers::servers(config, &io_cli::servers::Observed::default()) { + println!( + "{}\t{}\t{}", + server.id, + server.transport, + server.decided.word() + ); + } + } + io_cli::manage::Request::Mcp(io_cli::manage::McpVerb::Get { id }) => { + let found = io_cli::servers::servers(config, &io_cli::servers::Observed::default()) + .into_iter() + .find(|server| &server.id == id); + match found { + None => return Err(format!("no configuration file in force declares {id}")), + Some(server) => println!( + "{}\t{}\t{}", + server.id, + server.transport, + server.decided.word() + ), + } + } + io_cli::manage::Request::Plugin(io_cli::manage::PluginVerb::List) => { + let view = io_cli::pluginview::view(config); + for listed in &view.plugins { + println!("{}\t{}", listed.id, listed.root.display()); + } + for refused in &view.refused { + // stderr, because a refused bundle is not part of the list a + // script asked for — and it is exactly what an operator piping + // the list needs to see anyway. + eprintln!("{}: {}", refused.path.display(), refused.error); + } + } + io_cli::manage::Request::Config(io_cli::manage::ConfigVerb::Get { key }) => { + let setting = io_cli::configure::setting(config, key); + println!( + "{}\t{}\t{}", + setting.path, + setting.value.as_deref().unwrap_or(""), + setting.decided.word() + ); + } + io_cli::manage::Request::Config(io_cli::manage::ConfigVerb::List) => { + // **The origin column, and it is not optional.** A headless listing + // that omitted it would be a second, weaker truth about the same + // configuration than the one `/config` tells — and the whole argument + // of that surface is that a value without its deciding file is half an + // answer. A key no file names prints `default` and no path. + for setting in io_cli::configure::settings(config) { + println!( + "{}\t{}\t{}", + setting.path, + setting.value.as_deref().unwrap_or(""), + setting.decided.word() + ); + } + } + _ => {} + } + let Some(plan) = io_cli::manage::plan(root, &request)? else { + return Ok(io_cli::exec::OK); + }; + 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 + // the policy in force would refuse it would make the file depend on the + // posture at the moment of typing. On stderr, and exiting zero, because the + // operation did happen. + if let io_cli::manage::Request::Mcp(io_cli::manage::McpVerb::Add { server, .. }) = &request { + let policy = config.policy().unwrap_or_default(); + eprintln!("{}", io_cli::preflight::line(&io_cli::preflight::check(server, &policy))); + } + Ok(io_cli::exec::OK) +} + /// The values one setting can take, as a picker, or `None` when it has to be typed. /// /// **The kind decides, and every option comes from somewhere that cannot go From 30cd1a7c72cd08355d04cf1beffe1753106d9c87 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 10:33:57 +0530 Subject: [PATCH 10/15] =?UTF-8?q?chore(develop):=20The=20argument=20forms?= =?UTF-8?q?=20=E2=80=94=20`io=20mcp`,=20`io=20plugin`=20and=20`io=20conf?= =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.28.0-T09 --- src/cli.rs | 32 ++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 33 insertions(+) diff --git a/src/cli.rs b/src/cli.rs index 3258d46..ba46b0e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -79,6 +79,38 @@ pub enum Command { /// question, decide its plan, or say what happened to the call it stopped /// mid-way through. Resume(Resume), + /// Add, list, inspect, change or remove an MCP server, without opening a + /// session. + Mcp(Manage), + /// Add, list or remove a capability bundle, without opening a session. + Plugin(Manage), + /// Read or write one configuration key, without opening a session. + Config(Manage), +} + +/// The three management subcommands' arguments, handed through untouched. +/// +/// **Every token, verbatim, straight to `crate::manage::parse`** — which is what +/// makes `io mcp add …` and `/mcp add …` one parse rather than two that agree +/// today. clap is deliberately given no schema for what is inside: a second +/// grammar expressed in `#[arg]` attributes is precisely the second +/// implementation F6 compares bytes to rule out. +/// +/// `trailing_var_arg` and `allow_hyphen_values` are both load-bearing and neither +/// is optional. Without them clap consumes `--store` in +/// `io mcp add semlith -- semlith --store mcp` as an unknown flag of io's +/// own and the line dies before `manage` sees it — F7's whole subject. Nothing in +/// `manage.rs` can compensate; the tokens have to arrive intact. +/// +/// The flags are not documented here on purpose. `io mcp --help` shows this +/// paragraph and `manage`'s refusals name the accepted shapes at the point of +/// getting one wrong, which is where an operator is actually looking; a flag list +/// repeated in a doc comment is a second place for the grammar to drift. +#[derive(Debug, clap::Args)] +pub struct Manage { + /// The verb and its arguments — `add semlith -- semlith --store mcp`. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub words: Vec, } /// `io exec` — the headless entry point. diff --git a/src/lib.rs b/src/lib.rs index 63538ba..23d843c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -74,6 +74,7 @@ pub mod import; pub mod intent; pub mod keys; pub mod lock; +pub mod manage; pub mod markdown; pub mod memory; pub mod page; From 1c9970df6d17ecf371b783118b77278dbd14af69 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 10:37:27 +0530 Subject: [PATCH 11/15] docs(0.28.0): the guided management surface, and the version bump --- CHANGELOG.md | 73 ++++++++++++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 93 ++++++++++++++++++++++------------------------ src/manage.rs | 5 +-- src/preflight.rs | 11 +++--- tests/commands.rs | 45 ++++++++++++++++++++-- tests/configure.rs | 54 ++++++++++++++++++++++----- tests/plugins.rs | 10 ++++- tests/preflight.rs | 15 ++++++-- tests/providers.rs | 22 ++++++++--- tests/values.rs | 10 +++-- 12 files changed, 256 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c786dee..a5d9400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,79 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.28.0] - 2026-08-29 + +Every list this interface can prune, it can now grow; and every setting whose +values are knowable is chosen rather than typed. + +**All thirty-seven settings stop being free text.** Until now, choosing a row in +`/config` prefilled the composer with the key and left the value to be guessed — +so an operator setting `policy.defaults.write` was being asked to type a value +out of a set the pinned dependency has made public. A boolean is now toggled, a +closed enum cycled, a number chosen from a one-two-five ladder, a model taken +from `[prices.models]` already in the file, and a file taken from the workspace. +What is still typed is only what no menu can hold — a substring, a rubric, a URL, +a command — and each of those states the shape it wants and shows a worked +example before it asks. + +**Horizontal arrows cycle a value where it stands, and it cannot be the +spacebar.** Every picker in this product consumes printable characters as a +fuzzy filter, so Space would toggle a setting in the middle of a two-word query. +The arrows were already free. + +**Unsetting a key removes it, rather than writing a default's text into your +file.** This needed a new write primitive: `Edit::remove` takes a whole +`[section]` or `[[array]]` entry and errors on a key path, and nothing in the +codebase deleted a single `key = value` line. Writing the default's text instead +would have attributed a crate default to a file you never wrote it in — a lie a +reader cannot detect, and the exact one `/config`'s origin column exists to +prevent. After an unset, the origin column says `default` and names no file. + +**A write goes into the file that already decides the key**, stated in the +confirmation, rather than asking every time or silently choosing your own file. +Answering "the user scope" every time would shadow a committed project setting +with a personal one, which is the change you are least able to see afterwards. + +**`/provider`, `/mcp` and `/plugin` each gained the verb they never had.** +`servers::add` had been written and tested seven times over while reachable from +no keystroke at all; `pluginview::add` had no caller whatsoever. `/provider` also +gains the edit it has never had — until now a rotated API key meant opening a +file. + +**Adding an MCP server says whether it will be allowed to start.** A stdio server +starts under an `Act::Exec` check on its binary and an HTTP one under an +`Act::Net` check on its host, so a server the policy refuses dies before its +process exists. io now reports that at the moment of adding and names the rule +and the layer that decided it, instead of leaving it to be found on the next run. +The report is a disclosure and not a veto: the entry is written either way and +the command exits zero, because configuring a server before writing the rule that +lets it run is an ordinary order to do things in. + +**Note that HTTP servers are refused by default.** Every permission posture sets +`net` to deny, so `io mcp add --url …` will report a refusal until a rule names +the host. That is the boundary working, and the report says how to open it. + +**Every verb has an argument form, and both forms share one parse.** + +``` +io mcp add semlith -- semlith --store /path/to/.semlith mcp +io mcp add --transport http linear-server https://mcp.linear.app/mcp +io plugin add ./bundles/rust-review +io config set policy.defaults.write ask +io config unset app.io-cli.plain +io config list +``` + +`io mcp`, `io plugin` and `io config` open no session, start no run and touch no +store, so they work in CI. `io config list` prints the origin column, because a +value without its deciding file is half an answer and the headless surface must +not tell a weaker truth than the interactive one. A line written for another +harness — `--transport http` before the id, with the URL as a positional — is +accepted through the same parse rather than a second branch. + +**No command was added.** Everything here is a verb inside a command that already +existed. + ## [0.27.0] - 2026-08-28 The store stops being something you cannot see, undo stops being diff --git a/Cargo.lock b/Cargo.lock index b5ff436..589be0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1850,7 +1850,7 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.27.0" +version = "0.28.0" dependencies = [ "clap", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index f118dbf..7a19929 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.27.0" +version = "0.28.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 diff --git a/src/main.rs b/src/main.rs index c7e0ed5..e0c6043 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1190,9 +1190,9 @@ async fn loop_over Result>( if let Some(forward) = step { let row = open.selection(); let chosen = row.and_then(|row| paths.get(row)).cloned(); - if let Some(key_name) = chosen.filter(|name| { - name.as_str() != io_cli::configure::REFRESH_PRICES - }) { + if let Some(key_name) = + chosen.filter(|name| name.as_str() != io_cli::configure::REFRESH_PRICES) + { cycle_setting( session.root(), &mut config, @@ -2144,12 +2144,10 @@ async fn loop_over Result>( let ready: Vec = ["openrouter", "anthropic", "openai"] .into_iter() .filter(|preset| { - io_cli::providers::variable(io_cli::providers::Endpoint::Preset( - preset, - )) - .is_some_and(|name| { - io_cli::providers::variable_is_set(&name) - }) + io_cli::providers::variable( + io_cli::providers::Endpoint::Preset(preset), + ) + .is_some_and(|name| io_cli::providers::variable_is_set(&name)) }) .map(str::to_string) .collect(); @@ -2294,10 +2292,7 @@ async fn loop_over Result>( // a credential nor a model, so the list comes // first and the check follows it, still before any // edit — which is what F10 actually asks for. - app.record( - Tone::Muted, - "reading the model catalogue…".to_string(), - ); + app.record(Tone::Muted, "reading the model catalogue…".to_string()); paint(screen, &mut app)?; let models: Vec = io_cli::verify::served(None) .await @@ -2394,10 +2389,9 @@ async fn loop_over Result>( let (scope, edit) = match at { // A change to a link that already exists goes // into the file that already carries it. - Some(at) => ( - at.scope, - io_cli::providers::edit(at, "model", model), - ), + Some(at) => { + (at.scope, io_cli::providers::edit(at, "model", model)) + } // A new link goes to the user scope and is // written with the credential shape that needs // no secret in the file: no `api_key` for a @@ -2426,9 +2420,7 @@ async fn loop_over Result>( Ok(()) => { match io_cli::configure::reload(&root) { Ok((fresh, _)) => config = fresh, - Err(error) => { - app.record(Tone::Error, error) - } + Err(error) => app.record(Tone::Error, error), } // **Where it landed in the chain, // said before it matters.** A new @@ -2437,8 +2429,7 @@ async fn loop_over Result>( // force — an operator who added one // expecting it to answer the next // turn needs to be told it will not. - let place = - io_cli::providers::chain(&config).len(); + let place = io_cli::providers::chain(&config).len(); app.record( Tone::Success, match at { @@ -2479,10 +2470,7 @@ async fn loop_over Result>( // The model row descends rather than writing, so it is // taken before the edit is worked out. if index == *model_at { - app.record( - Tone::Muted, - "reading the model catalogue…".to_string(), - ); + app.record(Tone::Muted, "reading the model catalogue…".to_string()); paint(screen, &mut app)?; let models: Vec = io_cli::verify::served(None) .await @@ -2502,9 +2490,7 @@ async fn loop_over Result>( Picker::new( format!("Which model for {label}?"), std::iter::once(Row::new("leave it".to_string())) - .chain( - models.iter().map(|m| Row::new(m.clone())), - ) + .chain(models.iter().map(|m| Row::new(m.clone()))) .collect(), ), Pick::ProviderModel { @@ -2651,8 +2637,7 @@ async fn loop_over Result>( let current = io_cli::configure::setting(&config, key) .value .unwrap_or_default(); - descended = - Some(write_where(&root, key.clone(), current)); + descended = Some(write_where(&root, key.clone(), current)); } else if index == *unset_at { match io_cli::configure::write( &root, @@ -3448,11 +3433,9 @@ async fn loop_over Result>( Action::Manage(line) => { let root = session.root().to_path_buf(); let tokens = io_cli::manage::tokens(&line); - match io_cli::manage::parse(&tokens) - .and_then(|request| { - io_cli::manage::plan(&root, &request) - .map(|plan| (request, plan)) - }) { + match io_cli::manage::parse(&tokens).and_then(|request| { + io_cli::manage::plan(&root, &request).map(|plan| (request, plan)) + }) { // The refusals are finished sentences naming what was // wrong and what is accepted, so they are printed as they // came rather than re-worded into a second opinion about @@ -3463,7 +3446,10 @@ async fn loop_over Result>( // is asked, because a panel is a better answer in a session // than a text dump is. Answered from the configuration this // session is running on. - Ok((io_cli::manage::Request::Mcp(io_cli::manage::McpVerb::Get { id }), None)) => { + Ok(( + io_cli::manage::Request::Mcp(io_cli::manage::McpVerb::Get { id }), + None, + )) => { match io_cli::servers::servers(&config, &app.servers) .into_iter() .find(|server| server.id == id) @@ -3490,10 +3476,9 @@ async fn loop_over Result>( Ok(()) => { match io_cli::configure::reload(&root) { Ok((fresh, stored)) => { - capabilities = - io_cli::contract::Capabilities::stored( - stored.as_ref(), - ); + capabilities = io_cli::contract::Capabilities::stored( + stored.as_ref(), + ); config = fresh; } Err(error) => app.record(Tone::Error, error), @@ -3532,8 +3517,7 @@ async fn loop_over Result>( app.posture(), app.remembered(), ); - let report = - io_cli::preflight::check(server, &policy); + let report = io_cli::preflight::check(server, &policy); app.record( if report.starts() { Tone::Muted @@ -7318,7 +7302,10 @@ fn manage_main( // operation did happen. if let io_cli::manage::Request::Mcp(io_cli::manage::McpVerb::Add { server, .. }) = &request { let policy = config.policy().unwrap_or_default(); - eprintln!("{}", io_cli::preflight::line(&io_cli::preflight::check(server, &policy))); + eprintln!( + "{}", + io_cli::preflight::line(&io_cli::preflight::check(server, &policy)) + ); } Ok(io_cli::exec::OK) } @@ -7408,9 +7395,15 @@ fn value_rows( // "the user scope" every time would silently shadow a committed project // setting with a personal one. let title = if inherited { - format!("{key} — writes to the {} file, which decides it", word.word()) + format!( + "{key} — writes to the {} file, which decides it", + word.word() + ) } else { - format!("{key} — no file names it, so this writes to the {} file", word.word()) + format!( + "{key} — no file names it, so this writes to the {} file", + word.word() + ) }; Some(( Picker::new(title, rows), @@ -7471,8 +7464,7 @@ fn cycle_setting( // committed file makes the whole file stop parsing rather than that setting // being rejected. `write` would catch it and report io-harness's own sentence // — this reports it without touching the file at all. - if scope == io_harness::config::Scope::Project - && io_cli::configure::widens_project(key, &next) + if scope == io_harness::config::Scope::Project && io_cli::configure::widens_project(key, &next) { app.record( Tone::Refused, @@ -7484,7 +7476,10 @@ fn cycle_setting( ); return; } - let was = setting.value.clone().unwrap_or_else(|| "default".to_string()); + let was = setting + .value + .clone() + .unwrap_or_else(|| "default".to_string()); let edit = io_cli::edit::Edit::set(key, io_cli::configure::spell_value(&kind, &next)); match io_cli::configure::write(root, scope, &[edit]) { Err(refusal) => app.record(Tone::Refused, refusal), diff --git a/src/manage.rs b/src/manage.rs index f28cf46..c13fb1d 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -133,10 +133,7 @@ pub enum ConfigVerb { scope: Option, }, /// Delete one key's line, so the layer below it decides again. - Unset { - key: String, - scope: Option, - }, + Unset { key: String, scope: Option }, /// Every key this surface offers. List, } diff --git a/src/preflight.rs b/src/preflight.rs index deb27d4..32fd640 100644 --- a/src/preflight.rs +++ b/src/preflight.rs @@ -217,11 +217,10 @@ impl Preflight { /// whichever transport lost the coin toss, which is why the asymmetry is /// expressed here once, keyed on the act the harness itself keys on. pub fn starts(&self) -> bool { - match (self.act, self.outcome) { - (Act::Exec, Outcome::Permitted) => true, - (Act::Net, Outcome::Permitted | Outcome::Ask) => true, - _ => false, - } + matches!( + (self.act, self.outcome), + (Act::Exec, Outcome::Permitted) | (Act::Net, Outcome::Permitted | Outcome::Ask) + ) } } @@ -258,7 +257,7 @@ pub fn check(server: &McpServer, policy: &Policy) -> Preflight { outcome: Outcome::Unresolvable, rule: None, layer: None, - } + }; } }, }; diff --git a/tests/commands.rs b/tests/commands.rs index 83e82ad..8d34b6c 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -325,9 +325,48 @@ fn f12_resume_says_it_answers_a_parked_run_rather_than_merely_reopening_a_sessio assert_eq!( COMMANDS.len(), 36, - "0.27.0 adds exactly three commands — `/store`, `/export` and `/undo` — on \ - top of the thirty-three 0.26.0 shipped; a thirty-seventh here means one \ - arrived unrecorded", + "0.28.0 adds NO command — every verb it adds is a verb inside a command \ + that already exists, and `io mcp|plugin|config` are binary subcommands in \ + `src/cli.rs` rather than slash commands. A thirty-seventh here means one \ + arrived unrecorded, on top of the thirty-six 0.27.0 shipped", + ); +} + +/// **The palette did not grow, and no group was re-filed.** +/// +/// The other half of the assertion above, and the reason it is worth its own +/// test: `f13_no_group_is_longer_than_ten` caps a group at ten, and occupancy is +/// Session 7, Turn 10, Inspect 10, Configure 9 — **one free slot in the whole +/// product.** A release that added three commands would have had to re-file the +/// groups, which is a change to a surface nobody asked to change. Growing the +/// palette has to be a deliberate act rather than a side effect of adding a verb. +/// +/// Sabotage: add a command to `COMMANDS` and put it in a group. Under it this +/// fails on the occupancy it was given, naming the group that moved. +#[test] +fn o2_the_palette_did_not_grow_and_no_group_was_refiled() { + use io_cli::commands::{Group, GROUPS}; + + let occupancy: Vec<(Group, usize)> = GROUPS + .iter() + .map(|(group, names)| (*group, names.len())) + .collect(); + let total: usize = occupancy.iter().map(|(_, count)| count).sum(); + assert_eq!( + total, + COMMANDS.len(), + "every command is in exactly one group, so the group sizes must sum to the inventory" + ); + // The occupancy this release inherited and must leave alone. A written-out + // literal rather than a bound, so a group that grew while another shrank is + // named rather than cancelling out. + let mut sizes: Vec = occupancy.iter().map(|(_, count)| *count).collect(); + sizes.sort_unstable(); + assert_eq!( + sizes, + vec![7, 9, 10, 10], + "0.28.0 re-files no group; the occupancy is Session 7, Configure 9, Turn 10, Inspect 10, \ + and there is exactly one free slot in the product" ); } diff --git a/tests/configure.rs b/tests/configure.rs index 0547d11..46f5b5c 100644 --- a/tests/configure.rs +++ b/tests/configure.rs @@ -697,7 +697,11 @@ fn f2_the_exec_modes_are_spelled_by_the_dependency() { for mode in &modes { let s = scopes(&format!("[sandbox]\nmode = \"{mode}\"\n"), "", ""); assert_eq!( - s.config().sandbox().expect("a sandbox section").mode.as_str(), + s.config() + .sandbox() + .expect("a sandbox section") + .mode + .as_str(), mode, "io-harness refuses the mode io-cli offers: {mode}" ); @@ -734,11 +738,31 @@ fn f2_the_exec_modes_are_spelled_by_the_dependency() { #[test] fn f2_a_widening_value_is_legal_in_one_scope_and_refused_in_another() { let widening = [ - ("policy.defaults.exec", "allow", "[policy]\ndefaults = { exec = \"allow\" }\n"), - ("policy.defaults.net", "allow", "[policy]\ndefaults = { net = \"allow\" }\n"), - ("sandbox.allow_network", "true", "[sandbox]\nallow_network = true\n"), - ("sandbox.force_floor", "false", "[sandbox]\nforce_floor = false\n"), - ("sandbox.mode", "full-access", "[sandbox]\nmode = \"full-access\"\n"), + ( + "policy.defaults.exec", + "allow", + "[policy]\ndefaults = { exec = \"allow\" }\n", + ), + ( + "policy.defaults.net", + "allow", + "[policy]\ndefaults = { net = \"allow\" }\n", + ), + ( + "sandbox.allow_network", + "true", + "[sandbox]\nallow_network = true\n", + ), + ( + "sandbox.force_floor", + "false", + "[sandbox]\nforce_floor = false\n", + ), + ( + "sandbox.mode", + "full-access", + "[sandbox]\nmode = \"full-access\"\n", + ), ]; for (key, value, project) in widening { assert!( @@ -760,9 +784,21 @@ fn f2_a_widening_value_is_legal_in_one_scope_and_refused_in_another() { // The narrowing values of the same keys stay legal in a committed file, which // is what the scope is for — and are not marked. for (key, value, project) in [ - ("policy.defaults.exec", "deny", "[policy]\ndefaults = { exec = \"deny\" }\n"), - ("sandbox.mode", "read-only", "[sandbox]\nmode = \"read-only\"\n"), - ("sandbox.allow_network", "false", "[sandbox]\nallow_network = false\n"), + ( + "policy.defaults.exec", + "deny", + "[policy]\ndefaults = { exec = \"deny\" }\n", + ), + ( + "sandbox.mode", + "read-only", + "[sandbox]\nmode = \"read-only\"\n", + ), + ( + "sandbox.allow_network", + "false", + "[sandbox]\nallow_network = false\n", + ), ] { assert!( !configure::widens_project(key, value), diff --git a/tests/plugins.rs b/tests/plugins.rs index 14670a1..cd93c0e 100644 --- a/tests/plugins.rs +++ b/tests/plugins.rs @@ -948,7 +948,10 @@ fn f11_candidates_are_the_directories_carrying_a_manifest() { std::fs::create_dir_all(root.join("bundles/notes")).expect("a plain directory"); let found = pluginview::candidates(&root); - assert!(found.contains(&review), "the bundle must be offered: {found:?}"); + assert!( + found.contains(&review), + "the bundle must be offered: {found:?}" + ); assert!( !found.contains(&root.join("bundles/notes")), "a directory with no manifest must not be offered: {found:?}" @@ -1060,7 +1063,10 @@ fn f11_an_added_bundle_loads_through_the_harness() { view.refused ); assert_eq!( - view.plugins.iter().map(|p| p.id.as_str()).collect::>(), + view.plugins + .iter() + .map(|p| p.id.as_str()) + .collect::>(), vec!["rust-review"], ); } diff --git a/tests/preflight.rs b/tests/preflight.rs index 56aea63..40656b4 100644 --- a/tests/preflight.rs +++ b/tests/preflight.rs @@ -130,7 +130,10 @@ fn target_drops_the_userinfo() { /// port `1`. #[test] fn target_keeps_the_brackets_on_an_ipv6_literal() { - assert_eq!(preflight::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(), Some("[::1]:8080"), @@ -319,8 +322,14 @@ fn an_unresolvable_url_is_a_refusal_and_never_a_start() { !p.starts(), "{url}: `NetGuard::check` returns `Error::Refused` for this, so it does not start", ); - assert_eq!(p.target, url, "the URL as written is what the refusal carries"); - assert_eq!(p.rule, None, "no rule decided it — there was nothing to check"); + assert_eq!( + p.target, url, + "the URL as written is what the refusal carries" + ); + assert_eq!( + p.rule, None, + "no rule decided it — there was nothing to check" + ); assert_eq!(p.layer, None); let line = preflight::line(&p); diff --git a/tests/providers.rs b/tests/providers.rs index cf949ff..51f49c9 100644 --- a/tests/providers.rs +++ b/tests/providers.rs @@ -556,8 +556,12 @@ fn f10_editing_a_model_changes_one_key_and_leaves_the_file_alone() { // fail here — and each of those is what a surface that rebuilt the array // from io-cli's model of it would do, silently, on every edit. let at = At::of(Scope::User, THREE, 1).expect("the middle link"); - let edit = providers::edit(&at, "model", &io_cli::servers::quoted("llama-3.1-8b-instant")) - .expect("`model` is one of the keys this surface changes"); + let edit = providers::edit( + &at, + "model", + &io_cli::servers::quoted("llama-3.1-8b-instant"), + ) + .expect("`model` is one of the keys this surface changes"); let after = io_cli::edit::apply(THREE, &[edit]).unwrap(); assert_eq!( @@ -569,7 +573,10 @@ fn f10_editing_a_model_changes_one_key_and_leaves_the_file_alone() { // And io-harness reads back the link the operator meant, in its own place. let chain = providers::chain(&config(&after).config); assert_eq!(chain[1].model, "llama-3.1-8b-instant"); - assert_eq!(chain[1].kind, "groq", "the edit changed the entry's identity"); + assert_eq!( + chain[1].kind, "groq", + "the edit changed the entry's identity" + ); assert_eq!(chain[0].kind, "openrouter"); assert_eq!(chain[2].model, "llama3.2"); assert_eq!( @@ -611,7 +618,9 @@ fn f10_a_credential_is_written_in_whichever_of_the_three_shapes_was_chosen() { // for io-harness to fall back to here, so an absent key would be an // unauthenticated request rather than an authenticated one. assert_eq!( - Key::Environment.written(Endpoint::Preset("groq")).as_deref(), + Key::Environment + .written(Endpoint::Preset("groq")) + .as_deref(), Some("${env:GROQ_API_KEY}"), ); // Except where there is genuinely no credential to name. @@ -704,7 +713,10 @@ fn f10_unsetting_a_credential_deletes_the_line_rather_than_emptying_it() { "the key is still in the file: {after}", ); assert!(!after.contains("api_key"), "{after}"); - assert!(!after.contains("supersecret"), "the literal survived: {after}"); + assert!( + !after.contains("supersecret"), + "the literal survived: {after}" + ); // And the entry is still a link, which is the half a deletion could break: // `unset` takes one line and leaves the `[[provider]]` around it standing. diff --git a/tests/values.rs b/tests/values.rs index 791d761..782d5fb 100644 --- a/tests/values.rs +++ b/tests/values.rs @@ -188,8 +188,8 @@ fn f12_a_stated_shape_shows_an_example() { "app.io-cli.gates.rubric", "app.io-cli.prices.source_url", ] { - let said = configure::shape_of(key, &config) - .unwrap_or_else(|| panic!("{key} states no shape")); + let said = + configure::shape_of(key, &config).unwrap_or_else(|| panic!("{key} states no shape")); assert!( said.contains("for example:"), "{key} states a shape with no worked example: {said}" @@ -359,5 +359,9 @@ fn n4_the_picker_filter_still_owns_the_space() { io_cli::picker::Outcome::Idle )); } - assert_eq!(picker.selected(), before, "an arrow must not move the marker"); + assert_eq!( + picker.selected(), + before, + "an arrow must not move the marker" + ); } From 8ad281dc6ee20964523732cd333f21eee1794fa4 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 10:41:52 +0530 Subject: [PATCH 12/15] test(0.28.0): the F14 driver gate, and the palette that did not grow --- README.md | 184 ++++++++++++++++++++++++++++++++++++++++++++++++ tests/manage.rs | 75 ++++++++++++++++++++ 2 files changed, 259 insertions(+) diff --git a/README.md b/README.md index b4128aa..235c683 100644 --- a/README.md +++ b/README.md @@ -435,6 +435,32 @@ how the next line joins the queue. It is the same trade the fleet view makes wit the same two arrows, and `/steer` is what sends the queue into the turn rather than waiting for it. +**Two more are borrowed by `/config` from 0.28.0**, and they are not in the table +above for the same reason: they mean nothing anywhere else. `Right` and `Left` on +a `/config` row whose setting is a boolean or a closed set of words change it to +the next value — and the value after that, and back again — writing each one and +redrawing the row from the file's own answer rather than from an account of what +was just done. Nothing else in the product moves under a horizontal arrow, so +this takes no key away from any other surface. + +**It is the arrows and not the spacebar, and that is a compromise rather than a +preference.** `Space` is the obvious key for a toggle and it is unavailable: a +picker treats every printable character as a fuzzy filter, so the space in a +two-word query is a keystroke the list has already claimed, and binding it would +change a setting in the middle of typing a search for a different one. `Left` and +`Right` are simply the keys the picker does not want. The cost is discoverability +— an arrow does not announce itself the way a spacebar would, and nothing on the +row says to press one — and that is the honest state of it rather than something +this page will claim otherwise about. `Enter` opens the same values as a list, and +does everything the arrows do and more, so a key nobody finds costs a keystroke +and never a capability. + +A number is deliberately not cycled. Its values are a ladder rather than a pair, +too long to step through without seeing where you are, and a held arrow would +write the file once per key repeat — so `Enter` opens it instead. A key io-cli +does not know the values of says so and asks you to press `Enter`, rather than +absorbing the keystroke and looking broken. + ### Moving a key The keys the session itself owns can be rebound in `[app.io-cli.keys]`, by action @@ -547,6 +573,28 @@ above a row that ranked there for reasons having nothing to do with it. | `/gates` | the check a turn must pass before it is done: a command, a file, or a rubric | | `/import` | bring instructions, MCP servers, skills and a model across from another agent | +**No command was added in 0.28.0, and four of them stopped being half a surface.** +Every list here could already be pruned and none of it could be grown: `/mcp` +edited and removed servers and could not declare one, `/provider` promoted, +demoted and removed links and could not add one or change a key of one, `/plugin` +listed bundles and removed them and could not take one on, and `/config` named a +key and then left you to type a value out of a set it already knew. All four do +the other half now. No row was added above because none was wanted: a verb belongs +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. + +Which of them is typed and which is a row is not arbitrary. `/mcp add`, `/mcp +edit`, `/mcp remove`, `/mcp get`, `/config set` and `/config unset` are read by +the same parse `io mcp` 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 ` +still answers what that key is set to without writing anything. `/provider`'s two +new verbs are rows on its panel and never words you type: which link to add is +chosen from what your shell can already authenticate, and a list is the only +honest way to ask that. See [Without leaving the +session](#without-leaving-the-session). + `/effort` is new in 0.26.0 and sits under **this turn** beside `/model`, because the two are the same question asked twice: which model the work goes to, and how much thinking it is worth buying from it. It is a posture rather than a one-shot — @@ -1788,6 +1836,142 @@ The exit status is the table above: a resumed run that pauses again exits `4` naming the new pause, and `io resume --list` exits `0` whether or not it found 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. + +```sh +io mcp add semlith -- semlith --store /path/to/.semlith mcp +io mcp add linear --url https://mcp.linear.app/mcp --header 'Authorization=Bearer ${env:LINEAR_TOKEN}' +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 remove semlith + +io plugin add ./bundles/rust-review +io plugin list +io plugin remove ./bundles/rust-review + +io config get run.max_steps +io config set run.max_steps 40 +io config set app.io-cli.gates.command cargo test --all-features +io config unset run.max_steps +io config list +``` + +It is the same parse the composer uses. `/mcp add semlith -- semlith --store +/path/to/.semlith mcp` typed at the prompt and the first line above are one +sentence arriving through two doors, read by one function, planned into one edit +and written by one writer — so the two cannot come to write different bytes, which +is what two hand-written readings of one grammar always eventually do. + +**`--` ends io's arguments, and everything after it is the server's, verbatim.** +The `--store` on the first line is semlith's flag and never io's. A parser that +went on looking for its own past that point would eat an argument out of the +middle of somebody's command line and start a server that behaves differently +from the one they wrote down. + +**A URL means HTTP; a command after `--` means stdio.** That is the whole rule. +`--transport` is accepted because it is what another tool's users have learned to +type, and their muscle memory is not a thing to punish — but it is read as an +*assertion about the form* rather than as a way of choosing one. It is checked +against what you actually wrote and refused by name when the two disagree, so +`--transport stdio --url …` is a sentence naming which half to delete rather than +a silent discard of one of them. The third line above is that other tool's +ordering — the flag, then the name, then the URL as a second word — and it +produces the same `[[mcp]]` entry as the second line, because a second positional +*is* a URL wherever the flag sits. `--env` is refused on an HTTP server and +`--header` on a stdio one, each saying which of the two the server actually takes. + +**`--scope user|project|local` says which file, where the file is yours to +choose.** `mcp add` and `plugin add` default to `user`, because that is the file +that is yours and is not committed — defaulting to `project` would put one +operator's server into a repository everyone else clones, which is a disclosure +rather than a convenience. `config set` and `config unset` have no default: with +no `--scope` they inherit the file already deciding that key, which is the only +answer that *changes* a setting instead of shadowing it with a copy somewhere +higher. `mcp edit`, `mcp remove` and `plugin remove` take no `--scope` at all and +refuse one by name — the change goes to the file that declares the entry, and a +scope chosen here would aim a position counted in one file's array at another +file's. + +**`io config list` prints the origin column**, tab-separated after the value: +`user`, `project`, `local`, or `default` for a key no file names. There is no flag +to drop it. A value without the file that decided it is half an answer — that is +the whole argument of the `/config` surface — and a headless listing that left it +out would be a second, weaker truth about the same configuration. + +**Only the answer goes to stdout.** `mcp list`, `mcp get`, `plugin list`, +`config get` and `config list` write tab-separated rows and nothing else, so they +pipe. Everything else goes to stderr, including a `[[plugin]]` entry that was +declared and dropped: it is not part of the list a script asked for, and it is +exactly what an operator reading that list needs to see. + +**A refusal exits `1` and writes nothing.** Every refusal names what was wrong and +what is accepted instead — there is no bare "invalid argument" in this parse, +because you are at a terminal with no `--help` open and a refusal that does not +say what to type next costs you a round trip to this page. + +**`io mcp add` reports whether the policy will let that server start, on stderr, +and exits `0` anyway.** An MCP server is the one piece of configuration whose +failure mode is silence: a refused entry looks exactly as valid as one that works, +and you find out on the next turn, from a run that ends before its first step. So +the entry is written and then the same `Policy::check` io-harness will ask is +asked here — naming the act, the target, and the rule and the layer that decided, +or saying the tier default did, which is a different repair. **It is a disclosure +and not a veto.** Declining to record what you typed because a policy you can edit +would currently refuse it would make your configuration file depend on the posture +at the moment of typing, so the write happens and the status stays `0`. A script +that wants the verdict reads stderr. + +The two doors ask slightly different policies, and each is right about the run it +describes. `io mcp add` asks the `[policy]` section of the configuration in force +— io-harness's own defaults where a file has none, and those *ask* on `exec`. +`/mcp add` in a session asks the policy that session is actually running under: +the same section, plus the posture `Shift+Tab` is on, plus whatever you have +allowed for this session. + +**Every HTTP MCP server is refused by default, and this is the paragraph to read +before filing a bug.** io-harness denies `net` unless a rule allows it — +`Policy::default()` does, a policy deserialized from a file with no `net` field +does, and all three of the postures `io setup` writes say `net = "deny"` +outright. So on almost every install: + +```sh +io mcp add linear --url https://mcp.linear.app/mcp +``` + +writes the entry, exits `0`, and prints on stderr: + +``` +`linear` will not start: net `mcp.linear.app:443` is denied by the policy's own default for that act (no rule matched). +``` + +Nothing is broken and nothing needs undoing. The server is declared and the +boundary has simply not been told about it. Naming the host in a policy layer is +what starts it: + +```toml +[[policy.layers]] +name = "mcp" +rules = [{ act = "net", effect = "allow", pattern = "mcp.linear.app" }] +``` + +A net rule matches with or without a port: the pattern above allows that host on +any port, `mcp.linear.app:443` allows exactly one, and `*.linear.app` works the +way a `*` works on a path. Servers are attached per turn, so the next turn is what +picks the rule up — there is nothing to re-add and nothing to restart. A stdio +server is a different question: it is +checked against `exec` on the command exactly as the file spells it, which the +sandboxed-workspace posture allows outright, so a server declared after a `--` +usually needs nothing added at all. + ## Configuration io-cli has no configuration parser. io-harness owns discovery and layering, and diff --git a/tests/manage.rs b/tests/manage.rs index 0ad7a7e..4b34ab0 100644 --- a/tests/manage.rs +++ b/tests/manage.rs @@ -635,3 +635,78 @@ fn every_refusal_names_what_was_wrong_and_what_is_accepted() { ); } } + +/// F14 — the headless listing prints the origin column, asserted against the +/// driver as text. +/// +/// **This gate exists because listing the criteria found F14's sabotage had no +/// site.** "Drop the origin column from the listing" is the arm the criterion +/// names, and the listing is rendered in `manage_main` inside `src/main.rs`, which +/// nothing under `tests/` can link. Every other criterion in this release has a +/// library gate; this one had none, and a criterion whose sabotage cannot be +/// executed is a criterion that is not being checked. That is the 0.23.0 shape — +/// three HIGH defects all in `src/main.rs`, invisible to 1,215 passing tests. +/// +/// A weak instrument, and the only one available. It is the same one +/// `tests/providers.rs`'s ordering gate and `tests/context_share.rs` already use. +/// +/// Sabotage: drop `setting.decided.word()` from the `ConfigVerb::List` arm — under +/// which this fails, and the headless answer starts disagreeing with `/config` +/// about which file decided a value. +#[test] +fn f14_the_headless_listing_prints_the_deciding_file() { + // Normalised: a Windows checkout has `\r\n`, and a gate that sliced on `"\n"` + // matched nothing and panicked on a green product in 0.19.0 and 0.23.0. + let driver = std::fs::read_to_string("src/main.rs") + .expect("the driver is beside the tests") + .replace("\r\n", "\n"); + let at = driver + .find("fn manage_main(") + .expect("the argument forms are answered by `manage_main`"); + let body = &driver[at..]; + let end = body + .find("\n/// The values one setting can take") + .unwrap_or(body.len()); + let body = &body[..end]; + + let list = body + .find("ConfigVerb::List") + .expect("`io config list` must be answered"); + let rest = &body[list..]; + // The arm ends at the next match arm. + let arm_end = rest.find("_ => {}").unwrap_or(rest.len()); + let arm = &rest[..arm_end]; + assert!( + arm.contains("decided.word()"), + "`io config list` does not print the deciding file, so the headless answer \ + disagrees with `/config` about the same configuration" + ); + assert!( + arm.contains("configure::settings"), + "the listing must come from the same reader `/config` draws, or the two \ + surfaces can list different keys" + ); + + // And the preflight disclosure goes to stderr, not stdout: a listing being + // piped must not have prose in it. F14 says a preflight refusal is not a + // failed operation, so it also must not change the exit status. + let preflight = body + .find("preflight::line") + .expect("an MCP add reports the policy preflight"); + // The statement, not the line: `cargo fmt` puts the macro's arguments on + // their own lines, so a one-line window sees the argument and never the + // macro. The 200 bytes before the call are that statement and its comment. + let statement = &body[preflight.saturating_sub(200)..preflight]; + assert!( + statement.contains("eprintln!"), + "the policy preflight must go to stderr, where it cannot contaminate a listing; \ + the 200 bytes before the call were: {statement:?}" + ); + assert!( + // `eprintln!` ends in `println!`, so the stdout macro has to be looked for + // with the stderr one taken out first — a needle that matches its own + // opposite is a gate that can never fail. + !statement.replace("eprintln!", "").contains("println!("), + "the policy preflight reached stdout, which puts prose in a pipe a script is reading" + ); +} From b6cb55d61cc412c5fcc1c7f84dff02e2ea23da79 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 10:52:48 +0530 Subject: [PATCH 13/15] fix: a config question was a write, and a machine-written key had two doors --- README.md | 230 ++++++++++++++++++++++++++++++++------- docs/config.example.toml | 138 +++++++++++++++++------ src/commands.rs | 40 +++++-- src/main.rs | 124 ++++++++++++++++++++- tests/commands.rs | 82 ++++++++++++++ 5 files changed, 530 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 235c683..12c5a0c 100644 --- a/README.md +++ b/README.md @@ -435,25 +435,30 @@ how the next line joins the queue. It is the same trade the fleet view makes wit the same two arrows, and `/steer` is what sends the queue into the turn rather than waiting for it. -**Two more are borrowed by `/config` from 0.28.0**, and they are not in the table -above for the same reason: they mean nothing anywhere else. `Right` and `Left` on +**Two more are borrowed by `/config` from 0.28.0**, and they are left out of the +table above for that same reason: it is what the session binds all the time, and +these are held for as long as one list is open and handed back when it shuts. +`Right` and `Left` on a `/config` row whose setting is a boolean or a closed set of words change it to the next value — and the value after that, and back again — writing each one and redrawing the row from the file's own answer rather than from an account of what -was just done. Nothing else in the product moves under a horizontal arrow, so -this takes no key away from any other surface. +was just done. No picker does anything with a horizontal arrow — they fall +through to a do-nothing arm — and the interception is scoped to this one list, so +it takes no key away from any other surface. The composer still moves its cursor +with them and an approval still moves between its answers; neither has the +keyboard while a picker is up. **It is the arrows and not the spacebar, and that is a compromise rather than a preference.** `Space` is the obvious key for a toggle and it is unavailable: a picker treats every printable character as a fuzzy filter, so the space in a two-word query is a keystroke the list has already claimed, and binding it would change a setting in the middle of typing a search for a different one. `Left` and -`Right` are simply the keys the picker does not want. The cost is discoverability -— an arrow does not announce itself the way a spacebar would, and nothing on the -row says to press one — and that is the honest state of it rather than something -this page will claim otherwise about. `Enter` opens the same values as a list, and -does everything the arrows do and more, so a key nobody finds costs a keystroke -and never a capability. +`Right` are simply the keys the picker does not want. The cost is +discoverability: an arrow does not announce itself the way a spacebar would, and +nothing on the row says to press one. That is a real limit of the compromise, and +stating it beats leaving it to be found. What makes it a small one is that `Enter` +opens the same values as a list and does everything the arrows do and more — so a +key nobody finds costs a keystroke rather than a capability. A number is deliberately not cycled. Its values are a ladder rather than a pair, too long to step through without seeing where you are, and a held arrow would @@ -574,7 +579,7 @@ above a row that ranked there for reasons having nothing to do with it. | `/import` | bring instructions, MCP servers, skills and a model across from another agent | **No command was added in 0.28.0, and four of them stopped being half a surface.** -Every list here could already be pruned and none of it could be grown: `/mcp` +Every list here could already be pruned and none of them could be grown: `/mcp` edited and removed servers and could not declare one, `/provider` promoted, demoted and removed links and could not add one or change a key of one, `/plugin` listed bundles and removed them and could not take one on, and `/config` named a @@ -583,11 +588,12 @@ 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. -Which of them is typed and which is a row is not arbitrary. `/mcp add`, `/mcp -edit`, `/mcp remove`, `/mcp get`, `/config set` and `/config unset` are read by -the same parse `io mcp` 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 +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 +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 ` still answers what that key is set to without writing anything. `/provider`'s two new verbs are rows on its panel and never words you type: which link to add is @@ -1966,11 +1972,16 @@ rules = [{ act = "net", effect = "allow", pattern = "mcp.linear.app" }] A net rule matches with or without a port: the pattern above allows that host on any port, `mcp.linear.app:443` allows exactly one, and `*.linear.app` works the way a `*` works on a path. Servers are attached per turn, so the next turn is what -picks the rule up — there is nothing to re-add and nothing to restart. A stdio -server is a different question: it is -checked against `exec` on the command exactly as the file spells it, which the -sandboxed-workspace posture allows outright, so a server declared after a `--` -usually needs nothing added at all. +picks the rule up — there is nothing to re-add and nothing to restart. + +A stdio server is a different question and usually a quieter one. It is checked +against `exec` on the command exactly as the file spells it, and a file written +for the sandboxed-workspace posture says `exec = "allow"`, so a server declared +after a `--` normally needs nothing added at all. A file with no `[policy]` +section is the case to know about: io-harness's own default *asks* on `exec`, and +a server is spawned before the first step of a run, with nobody there to ask — so +the preflight reports it as refused rather than as a question, which is what +io-harness will do to it. ## Configuration @@ -1986,9 +1997,64 @@ no file named names no file rather than being blamed on the lowest-precedence one: io-harness reports an empty origin for it, and that is its own default speaking. -Choosing a row puts its key in the prompt. `/config ` asks which of -the three files to write to, and only that choice writes. The change is in force -from the next turn. +**From 0.28.0 a value is chosen rather than typed.** Choosing a row used to put +its key in the prompt and leave the value to you — so setting +`policy.defaults.write` meant guessing a word out of a set the pinned dependency +has made public, and there was no way to tell a typo from an option that does not +exist. A row now descends into its own values: the three effects and the three +sandbox modes come from io-harness's own types rather than from a list here, a +model comes from the `[prices.models]` already in your file, a path comes from the +same workspace reader the composer's `@` opens, and a number comes from a ladder. +Nothing on that screen reaches the network, and there is no per-key table of +options to go stale. + +**A number descends into a one-two-five ladder built around the value in force.** +1, 2, 5 at each magnitude, ordered outwards from where you are, with the value the +file currently says always present as a rung whether or not it sits on the ladder +— because a list that quietly omits your own setting is a list you cannot find it +in. The anchor is the value in force and not a default, because there is no +default to anchor on: `max_tokens` and `max_duration_secs` are absent in both of +io-harness's contract constructors and `max_steps` is 8 in one and 12 in the +other, so "the default" is not a thing this surface could read. A key no file +names ladders from 1. `app.io-cli.gates.expect_exit` is the one signed key and its +ladder runs through zero into the negatives, because a process may legitimately +be expected to exit on one. + +**A horizontal arrow changes a boolean or a closed set of words where it stands**, +without opening anything — see [Keys](#keys) for why it is the arrows and not the +spacebar. Each press writes, re-reads and redraws the row from the file's own +answer rather than from an account of what was just done, and each one is +committed to the scrollback, so cycling through four values leaves four lines +saying what happened rather than one footer notice overwriting the other three. + +**Every row also offers *unset it*, which removes the key rather than writing a +default's text into a file.** The distinction is the one this whole surface opens +with: after an unset the origin column says `default` and names no path, which is +io-harness's own default speaking. Writing the default's *value* instead would +attribute a crate default to a file you never wrote it in, and that is a lie a +reader has no way to detect. + +**A write goes into the file already deciding the key, and the confirmation says +so before you choose.** Asking every time would cost more than the change did — +the value was chosen in one keystroke — and answering "your own file" every time +is worse than asking: it silently shadows a committed project setting with a +personal one, which is the change you are least able to see afterwards. A key no +file names has nothing to inherit and goes to your own file, and the title says +that too. *write it to another file…* is the row for moving a key between the +three scopes, and it carries the current value along so a move does not also ask +you to retype what you had. + +What is still typed is only what no menu can hold — a substring, a rubric, a URL, +a command — and each of those now says what shape it wants and shows a worked +example before the composer opens. Nothing opens a bare prompt with a key in it +and no candidates any more. + +Three ways in, and they differ in one thing. `/config` opens the list. +`/config ` is the shorthand this surface has always had and asks +which of the three files to write to. `/config set ` — and +`/config unset `, and `io config set` and `io config unset` — inherit the +deciding file the way the picker does, and take `--scope user|project|local` to +override it. The change is in force from the next turn. **Your file survives it.** The comments, the blank lines, the order you chose and every section io-cli has no type for come back byte for byte — one value's bytes @@ -2008,26 +2074,90 @@ the rule is about which file, not which value. io-cli keeps no copy of those rules: it writes, asks io-harness to read the file back, and restores it exactly when the answer is no. +**From 0.28.0 the row says so before it writes**, and that is worth a second +mechanism rather than being left to the round trip, because the cost is not one +key. There are exactly five (key, value) pairs a committed `io.toml` may not +carry — `policy.defaults.exec = "allow"`, `policy.defaults.net = "allow"`, +`sandbox.allow_network = true`, `sandbox.force_floor = false` and +`sandbox.mode = "full-access"` — and io-harness's check runs *before* the file is +deserialized, so choosing one of them in a project file does not get you a +rejected setting: it gets you a configuration that no longer parses. The write is +still verified by io-harness reading it back, and still rolled back to the exact +bytes that were there. What the row adds is that the file is not written at all, +and that the refusal says the whole file is what would have been refused. On +`config set` it goes further and names the two scopes that will take the value: +`--scope local` for this checkout, `--scope user` for yourself. + **`/mcp`** shows what is configured, which servers answered this session, how many tools each announced, how many distinct ones this session has asked for, and the last failure. A server the session has not reached says so and is not shown as -broken. From 0.21.0 it also **adds, edits and removes** `[[mcp]]` entries, through -the same write `/config` uses: staged, read back by io-harness, and rolled back -whole when the answer is no. It offers no *disable*, because `McpServer` has no +broken. From 0.21.0 it **edits and removes** `[[mcp]]` entries, through the same +write `/config` uses: staged, read back by io-harness, and rolled back whole when +the answer is no. **From 0.28.0 it declares them too** — `/mcp add -- +[args…]` for a server io starts, `/mcp add --url ` for one it dials, +with `--env`, `--header`, `--timeout-secs` and `--scope`, read by the same parse +`io mcp add` uses so the two doors write the same bytes. That is the verb this +panel was missing: `servers::add` existed, was tested, and was called from +nothing, so a list you could prune was a list you could not grow. See [Managing +the configuration without a +session](#managing-the-configuration-without-a-session) for the grammar, which is +one grammar. + +**Adding one reports whether the policy will let it start.** io-harness denies +`net` by default and every posture `io setup` writes says so, so an HTTP server +will report *will not start* for almost everyone until a `[[policy.layers]]` rule +names its host — which is not a bug and is the first thing to check. The report is +a disclosure and never a veto: the entry is written either way. It is where a +server is added, rather than at the run that first needs one, because a refused +entry looks exactly as valid as a working one and the alternative is finding out +a turn later. The same paragraph in [Managing the configuration without a +session](#managing-the-configuration-without-a-session) has the rule to write. + +It offers no *disable*, because `McpServer` has no key for one — an `enabled = false` invented here would be accepted by the file and ignored by the harness, and a panel saying "disabled" over a running server is worse than a panel with one fewer verb. Nor a *reconnect*: servers are attached per turn, so the next turn is what picks up your edit. **`/provider`** shows the `[[provider]]` array as what it is: the order a turn -tries them. From 0.21.0 you can **arrange** it — add an entry, promote one, -demote one, remove one — which is the fallback chain io-harness has supported -since its 0.27.0 and that this interface has drawn an event for without ever -being able to cause one. The twenty-one presets it reaches through one -`Compatible` provider are offered by name with the endpoint each resolves to. -Reordering moves an entry with its own comments and its own keys rather than -rebuilding the array, because a chain rebuilt from io-cli's model would silently -drop whatever io-cli does not model. +tries them. From 0.21.0 you can **arrange** it — promote an entry, demote one, +remove one — which is the fallback chain io-harness has supported since its +0.27.0 and that this interface has drawn an event for without ever being able to +cause one. Reordering moves an entry with its own comments and its own keys rather +than rebuilding the array, because a chain rebuilt from io-cli's model would +silently drop whatever io-cli does not model. + +**From 0.28.0 it also adds a link, and changes the model on one it already has.** +*Add a provider* offers the presets, reads the model catalogue that endpoint +actually serves and offers you one from it, then verifies the credential — in that +order, and the order is the guarantee rather than a preference: the check happens +before a single byte is written, so a rejected credential leaves your +configuration exactly as it was, and a catalogue that cannot be read is a reason +to send you to `io setup` rather than to refuse you for being offline. A new link +is appended, so it is a *fallback* and not the provider in force, and the line +that confirms it says which position it landed in — promote it if you meant it to +answer the next turn. Nothing writes a key into the file: a vendor entry is +written with no `api_key` line at all, which is what io-harness reads as "use my +own environment variable". + +*Change the model* is the only key this panel edits, from the same catalogue, and +it is deliberately narrow. `kind`, `preset` and `base_url` are the link's +*identity* — an entry pointed at a different vendor is a different link, and +remove-then-add says so in words rather than leaving behind the both-bases entry +that a `preset` written over a `base_url` would be. A model change is not a claim +about a credential, so nothing is verified for one: pinging an endpoint to rename +a field would spend your money answering a question nobody asked. + +**Add only offers a preset whose API-key variable is already set in your shell** — +`OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` — and names the +variable beside each, never anything about its contents. **That is a decision and +not a shortfall.** A credential that has to be *typed* has one flow in this +product: `io setup`, which asks for it, verifies it against the endpoint and +writes it. A second credential prompt grown inside the session loop would be a +second thing to keep correct, a second place a key can be pasted, and a second +answer to "where did my key end up". So this surface offers exactly the case that +needs no typing, and with no variable set it says so and sends you to the flow +that already exists. Export one and the row appears. **From 0.26.0 the chain is what runs.** Since 0.21.0 that panel has drawn the whole order while the product only ever asked its head, so a second @@ -2053,7 +2183,17 @@ tested and called from nothing, while three places in this documentation said th "add, edit, disable and remove". They genuinely write now, and the word *disable* is gone from that sentence because `/mcp` does not offer it. -Neither verb takes a row number. An entry is addressed by finding its id in the +**Half of that was still true until 0.28.0, and this page said otherwise.** What +0.21.0 actually reached was edit, promote, demote and remove. `servers::add` and +`providers::add` stayed exactly where the paragraph above found their siblings — +written, tested, and called from nothing — so both panels could shorten a list +neither of them could lengthen, and this page listed "add an entry" among +`/provider`'s verbs for seven releases over a row that was never drawn. 0.28.0 is +the release that makes it true, and this is the second time the same mistake has +had to be written down: a writer with no caller reads exactly like a feature to +whoever is documenting it. + +No verb here takes a row number. An entry is addressed by finding its id in the file's own bytes, because a row on screen and a position in a file's array are different numbers the moment anything sorts or filters, and getting that wrong does not fail loudly — it removes a server you never named, or bills the next turn @@ -2061,8 +2201,22 @@ to a vendor you did not choose. **`/plugin`** shows the capability bundles a `[[plugin]]` entry declared: what each one contributed, by name, and every bundle that was declared and dropped -with io-harness's own reason beside it. See [Capability -bundles](#capability-bundles). +with io-harness's own reason beside it. It has been able to remove one since +0.20.0; **from 0.28.0 it declares one as well.** The add row does not ask you to +type a path — it walks up to three directories below the workspace root, skipping +`target`, `node_modules` and anything dotted, and offers every directory that +carries a `plugin.toml`. A path typed from memory is a path that gets mistyped, +and io-harness's plugin loader has no error path: an entry naming a directory with +no manifest is *dropped*, recorded and otherwise silently absent, which is a bundle +you believe is loaded and is not. So existence is checked before the entry is +written and again on the keystroke that writes it, because a candidate can lose +its manifest between the row being drawn and the row being chosen. A directory +below the root is declared by its **relative** path, which is what makes a bundle +vendored into a repository work for everyone who clones it; one kept elsewhere is +written absolute. A bundle deeper than three directories, or outside the root +entirely, is named outright — `/plugin add `, or `io plugin add ` from +a shell — and is refused by the same check rather than by a shallower one. See +[Capability bundles](#capability-bundles). **`/profile`** switches to a named `[profile.]` for the session, and `--profile ` picks one for a single run without writing anything. diff --git a/docs/config.example.toml b/docs/config.example.toml index b4197ea..3fde07d 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -64,7 +64,26 @@ model = "anthropic/claude-sonnet-4" # NOTE: a PROJECT-scoped file may narrow this and may never widen it — a # repository you cloned must not be able to grant itself permission. Widening # belongs in the user-scope file, which is where `io setup` writes. +# +# THERE ARE EXACTLY FIVE (KEY, VALUE) PAIRS A COMMITTED io.toml MAY NOT WRITE, +# and io-harness refuses the WHOLE FILE for any one of them rather than dropping +# that key — the check runs before the file is deserialized, so what you get is a +# configuration that no longer parses rather than a rejected setting: +# +# policy.defaults.exec = "allow" +# policy.defaults.net = "allow" +# sandbox.allow_network = true +# sandbox.force_floor = false +# sandbox.mode = "full-access" +# +# The narrowing value of each stays legal in a project file, which is what the +# scope is for. Write any of the five in io.local.toml for this checkout, or in +# this user-scope file for yourself. `/config` knows the list and says so on the +# row BEFORE it writes, rather than writing and reporting the rollback. +# Each of the four takes "allow", "ask" or "deny" — io-harness's own three, and +# nothing else parses. `/config` offers them as a list rather than asking you to +# type one, and a horizontal arrow steps through them on the row. [policy.defaults] read = "allow" write = "allow" @@ -142,7 +161,18 @@ net = "deny" # because a key present with its own default is a key a reader has to wonder # about. # -# [run] +# EVERY COMMENT BELOW SAYS WHAT THE KEY TAKES, and that is not decoration: from +# 0.28.0 `/config` chooses a value rather than asking you to type one, and it +# decides what to offer from the same reading. A key whose values are a closed set +# gets that set — from io-harness's own types where the set is the harness's — a +# whole number gets a 1-2-5 ladder built around whatever the value in force is, a +# model name gets the models `[prices.models]` already prices, a path gets the +# workspace. What is left to type is only what no menu can hold: a substring, a +# rubric, a URL, a command line. So a mismatch between one of these comments and +# what io-cli offers is a defect in one of the two, and the comment is the half +# you can check. +# +# [run] # every key here is a WHOLE NUMBER, none negative # max_steps = 40 # how many steps one turn may take # max_tokens = 250000 # the token ceiling for one turn # max_duration_secs = 900 # wall clock for one turn @@ -151,16 +181,18 @@ net = "deny" # # [sandbox] # mode = "workspace-write" # read-only | workspace-write | full-access -# allow_network = false # whether a contained command may reach the network -# force_floor = true # keep the floor even where the caller asked for less +# allow_network = false # true or false: may a contained command reach the network +# force_floor = true # true or false: keep the floor even where the caller asked for less # # NOTE on the three sandbox keys above: `allow_network = true`, `force_floor = -# false` and `mode = "full-access"` are each REFUSED in a project-scoped file by -# io-harness itself, for the reason the policy note above gives. `/config` shows -# you the harness's own sentence when a write would do that, rather than a -# summary of it. +# false` and `mode = "full-access"` are three of the five pairs a project-scoped +# file may not write — see the list under the policy block above, and note that +# io-harness refuses the WHOLE FILE for one of them rather than that key. `/config` +# says so on the row before it writes, and shows you the harness's own sentence +# rather than a summary of it when a write does reach that refusal. # # [memory] # bounds the STORE, which outlives every run over it +# # every key here is a WHOLE NUMBER, none negative # max_entries = 500 # how many durable entries are kept # max_chars = 200000 # the total size of them # max_entry_chars = 4000 # the size of any one of them @@ -369,7 +401,11 @@ net = "deny" # whole file unreadable. io-cli's bookkeeping lives in [app.io-cli.prices] below. # # as_of the date the rates were read. REQUIRED: a price list with no date is -# a claim with no expiry. Written by a fetch rather than typed. +# a claim with no expiry. WRITTEN BY A FETCH RATHER THAN TYPED: a date +# set by hand is a claim about a fetch that never happened, and +# `io config set prices.as_of …` is refused by name for saying so. It +# is on `/config` to be READ, and the act beside it is the row that +# re-reads the catalogue and writes this itself. # models one entry per model, in micro-units PER MILLION TOKENS # # Rows for models the catalogue no longer serves are left alone rather than @@ -394,9 +430,9 @@ net = "deny" # defaults and says so, carrying the harness's own message. [app.io-cli] -# "dark" or "light". Absent means detect from the terminal background. +# Exactly "dark" or "light". Absent means detect from the terminal background. theme = "dark" -# How much of a change a diff shows. +# How much of a change a diff shows. Exactly "unified" or "minimal". # # "unified" the hunk as the harness stored it, context and all. The default, # and what an absent key means — so a file written before 0.3.0 @@ -404,7 +440,7 @@ theme = "dark" # "minimal" the changed lines and the @@ header, without the context. For # reviewing by file rather than by hunk. diff = "unified" -# Which marks to draw with: "unicode" or "ascii". +# Which marks to draw with: exactly "unicode" or "ascii". # # Absent — the default — asks the locale: LC_ALL, then LC_CTYPE, then LANG, and # the first one PRESENT decides, whatever it says. Name a set here when the locale @@ -416,16 +452,17 @@ diff = "unified" # # glyphs = "ascii" # -# Run every session in plain mode: no animation, the ASCII set forced, and each -# state the session enters committed to the scrollback as a line of text. The same -# switch as `--plain`, and the flag wins — there is no `--no-plain`, so a file that -# says true here cannot be turned off for one run. +# true or false. Run every session in plain mode: no animation, the ASCII set +# forced, and each state the session enters committed to the scrollback as a line +# of text. The same switch as `--plain`, and the flag wins — there is no +# `--no-plain`, so a file that says true here cannot be turned off for one run. # # false is the default and what an absent key means; it is written out so the file # says which way round it is. plain = false # -# Whether a prompt that is only a question may be answered in one completion. +# true or false. Whether a prompt that is only a question may be answered in one +# completion. # # io-harness has classified turns since long before io-cli asked it to: a contract # carrying no verification criterion turns classification on, and a question then @@ -520,9 +557,11 @@ fleet = "ctrl+f" # them in the half a session turn and an `io exec` run share, so what you write # here bounds your terminal exactly as it bounds CI. # -# max_parallel_reads = 10 # read-only tool calls at once; 0 clamps to 1 -# spawn_background_after_secs = 120 # when a slow child is backgrounded -# detached_spawns = true # whether a spawn may detach at all +# max_parallel_reads = 10 # a whole number: read-only tool calls at +# # once; 0 clamps to 1 +# spawn_background_after_secs = 120 # a whole number of seconds: when a slow +# # child is backgrounded +# detached_spawns = true # true or false: may a spawn detach at all # --- io-cli's own capabilities ---------------------------------------------- # @@ -593,7 +632,10 @@ fleet = "ctrl+f" # and live in [prices]; this is the bookkeeping io-harness does not model, in the # one section that will not refuse a key it has never heard of. # -# source_url the catalogue to read. ABSENT MEANS io-harness's own default, +# source_url a URL, and free text: no menu can hold one, so it is one of the +# few keys `/config` still opens a composer for — and it names the +# shape and shows an example first rather than opening a bare line. +# The catalogue to read. ABSENT MEANS io-harness's own default, # which is the reference catalogue. Write this when you are on a # self-hosted or `compatible` endpoint: the reference catalogue # cannot speak for a server it has never heard of, and this key is @@ -632,21 +674,32 @@ fleet = "ctrl+f" # precedence rule — a precedence rule here would quietly gate every turn on # something you did not choose, which is the one failure a gate must not have. # -# `retries` is how many further turns a failing gate may buy, and it defaults to -# 1. ZERO MEANS REPORT AND DO NOT RE-DRIVE: the failure is shown and the turn -# ends, which is what you want when a person is watching. It is a small number on -# purpose — a retry is a whole turn against a real model, not a loop iteration — -# so an operator who wants several says so. +# `retries` is a WHOLE NUMBER, none negative: how many further turns a failing +# gate may buy, defaulting to 1. ZERO MEANS REPORT AND DO NOT RE-DRIVE: the +# failure is shown and the turn ends, which is what you want when a person is +# watching. It is a small number on purpose — a retry is a whole turn against a +# real model, not a loop iteration — so an operator who wants several says so. # # --- 1. a command that must exit a status --------------------------------- # -# The argv of a program, not a shell line: io-harness checks argv[0] against this -# session's `exec` policy and runs it without a shell, so `&&`, a pipe and a glob -# are not available and were never going to be. `expect_exit` defaults to 0. +# `command` IS AN ARRAY OF STRINGS AND NEVER A STRING. It is the argv of a +# program, not a shell line: io-harness checks argv[0] against this session's +# `exec` policy and runs it without a shell, so `&&`, a pipe and a glob are not +# available and were never going to be. A scalar written here — `command = "cargo +# test"` — is a value io-harness cannot read back, which is why it is the one key +# in io-cli's whole catalogue with a list of its own rather than being lumped in +# with free text. `io config set app.io-cli.gates.command cargo test --all-features` +# takes the words after the key and writes them as the array. # # `command` IS ALWAYS A CONCRETE ARGV. An empty list is refused, and an absent one # is not a criterion at all — this key never means "work it out for me". # +# `expect_exit` IS A SIGNED WHOLE NUMBER and defaults to 0. It is the one key here +# that may go below zero — a process may legitimately be expected to exit on a +# negative status — and it is the only signed key in the whole catalogue, so +# `/config`'s ladder for it runs through zero into the negatives while every other +# number key stops there. +# # What detection is for is the OFFER: `/gates` asks io-harness what this repository # builds and tests with — it is the half of this that knows a Cargo workspace from # a package.json — and offers you that command as a row you can accept, which @@ -668,11 +721,14 @@ fleet = "ctrl+f" # # --- 2. a file that must exist, and optionally say something -------------- # -# The path is relative to the workspace root. `contains` is plain text the file -# must hold; absent, existence alone is the whole criterion. This is the cheap -# gate — nothing is run and nothing is billed — and it is the right one for work -# whose product is a file: a migration written, a changelog entry added, a -# generated artefact that has to appear. +# `file` is A PATH, relative to the workspace root — `/config` offers the +# workspace's own files rather than asking you to type one. `contains` is FREE +# TEXT: plain text the file must hold, which no menu can offer, so it is typed +# with the shape stated first. Absent, existence alone is the whole criterion. +# +# This is the cheap gate — nothing is run and nothing is billed — and it is the +# right one for work whose product is a file: a migration written, a changelog +# entry added, a generated artefact that has to appear. # # [app.io-cli.gates] # file = "CHANGELOG.md" @@ -680,8 +736,13 @@ fleet = "ctrl+f" # # --- 3. a rubric a second model answers ----------------------------------- # -# For work no exit status can judge. `rubric` is what the reviewer is asked to -# decide; `reviewer` is the model that decides it. +# For work no exit status can judge. `rubric` is FREE TEXT — the sentence the +# reviewer is asked to decide, typed, because nothing could offer it as a list. +# `reviewer` is A MODEL NAME, and `/config` offers the models `[prices.models]` +# above already prices: never a network call, because a settings screen that +# reached for a catalogue would be spending your money to draw a menu. A file with +# no priced models has no list to offer, and the row that re-reads the catalogue +# is what fills it. `allow_self_review` is TRUE OR FALSE. # # A RUBRIC REQUIRES A REVIEWER, AND THE REVIEWER MAY NOT BE THE MODEL DOING THE # WORK UNLESS `allow_self_review = true`. Both are refused when the section is @@ -743,6 +804,13 @@ fleet = "ctrl+f" # `bytes = 0` (which can never be true), and a model written as the empty string. # The refusal names the key and says the turn is not routed. # +# WHAT EACH OF THE FOUR TAKES. `failures` and `bytes` are WHOLE NUMBERS, neither +# negative; both `model` keys are MODEL NAMES, offered from the models +# `[prices.models]` already prices, the same list `[app.io-cli.gates] reviewer` +# draws from. All four keys are on `/config` and not a chosen two, because a +# threshold without its model is half a rule and an operator who met one half on +# that surface would write a section that refuses to parse. +# # THERE IS NO `require_primary` KEY, AND THAT IS A DECISION. io-harness's own # Routing carries the field; it gates on Provider::reachable, a defaulted trait # method whose body answers yes and which no provider in io-harness 0.69 diff --git a/src/commands.rs b/src/commands.rs index 2caa8de..96aff72 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -2056,18 +2056,44 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { None => Action::Export(Taken::Conversation(None)), } } - // **The verbs go to the one parse; the shorthand stays.** `/config set X - // Y`, `/config unset X`, `/config get X` and `/config list` are the words - // `io config` takes and reach the same `manage::parse`. `/config - // ` — no verb — is the shorthand this surface has always had and - // is what `/mcp`'s edit row and `/gates` still put in the composer; it - // keeps its own arm below rather than being rewritten into a verb the - // operator did not type. + // **The two verbs that WRITE go to the one parse; the shorthand stays.** + // `/config set X Y` and `/config unset X` reach the same `manage::parse` + // `io config set|unset` reaches, so the two doors cannot disagree about + // what lands in a file. The two that read are answered below by the arms + // that have always answered them. + // + // `/config ` — no verb — is the shorthand this surface has + // always had and is what `/mcp`'s edit row and `/gates` still put in the + // composer; it keeps its own arm below rather than being rewritten into a + // verb the operator did not type. "config" | "settings" if matches!(input.split_whitespace().nth(1), Some("set" | "unset")) => { Action::Manage(input.to_string()) } + // **`get` and `list` are answered by the arms that already answer them, + // and routing them anywhere else was a silent write.** Without this, + // `/config get run.max_steps` fell through to the shorthand below and was + // read as the key `get` with the value `run.max_steps` — so a question + // wrote a key called `get` into the operator's file. `/config list` was + // read as a question about a key named `list`. + // + // They do not go to `manage` either: in a session the answer to `list` is + // the panel and the answer to `get` is the sentence this surface has + // always given, and `io config get|list` is the same reading printed for a + // script. Two media for one reading — the rule the `/mcp` and `/plugin` + // guards above follow. + "config" | "settings" if input.split_whitespace().nth(1) == Some("list") => { + Action::Config(None) + } + "config" | "settings" if input.split_whitespace().nth(1) == Some("get") => { + match input.split_whitespace().nth(2) { + Some(key) => Action::Config(Some((key.to_string(), String::new()))), + // `get` with nothing after it is the panel, not a question about + // an empty key. + None => Action::Config(None), + } + } "config" | "settings" => { let mut rest = input.split_whitespace().skip(1); match rest.next() { diff --git a/src/main.rs b/src/main.rs index e0c6043..42e252b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2241,6 +2241,37 @@ async fn loop_over Result>( "chooses from the catalogue this provider serves" .to_string(), )); + // **Offered only where there is a secret + // in the file to take out.** An entry + // already reading its key from the + // environment has nothing to move, and a + // row that did nothing would be the + // advertised-but-inert shape 0.19.0 built + // a gate against. `usize::MAX` stands for + // "no such row" the way `promote`'s + // absence already does. + let credential_at = match entry.credential { + io_cli::providers::Credential::Written => { + let at = rows.len(); + rows.push(Row::with_detail( + "take its key out of the file".to_string(), + format!( + "removes the `api_key` line, so the key is \ + read from ${} instead", + io_cli::providers::variable( + io_cli::providers::Endpoint::Preset( + &entry.kind, + ), + ) + .unwrap_or_else(|| { + "the provider's variable".to_string() + }), + ), + )); + at + } + _ => usize::MAX, + }; let remove_at = rows.len(); rows.push(Row::new("remove this link from the chain")); descended = Some(( @@ -2251,6 +2282,7 @@ async fn loop_over Result>( first, kind: entry.kind.clone(), model_at, + credential_at, remove_at, }, )); @@ -2463,10 +2495,51 @@ async fn loop_over Result>( first, kind, model_at, + credential_at, remove_at, } => { let promote = if *first { usize::MAX } else { 1 }; let remove = *remove_at; + // **The one caller `providers::edit`'s `api_key` path + // has**, and it is the path that matters: an empty + // value becomes `Edit::unset`, which deletes the line, + // where writing `api_key = ""` would leave a key that + // `provider::key_for` returns as a valid empty + // credential — a 401 on every request with nothing on + // screen to explain it. + if index == *credential_at { + match io_cli::providers::edit(at, "api_key", "") { + None => app.record( + Tone::Refused, + "that link no longer carries a written key".to_string(), + ), + Some(edit) => { + match io_cli::configure::write( + session.root(), + at.scope, + &[edit], + ) { + Err(refusal) => app.record(Tone::Refused, refusal), + Ok(()) => { + if let Ok((fresh, _)) = + io_cli::configure::reload(session.root()) + { + config = fresh; + } + app.record( + Tone::Success, + format!( + "{label}'s key is out of the file; it is \ + read from the environment from the next \ + turn, and the file no longer carries a \ + secret" + ), + ); + } + } + } + } + } // The model row descends rather than writing, so it is // taken before the edit is worked out. if index == *model_at { @@ -2612,7 +2685,22 @@ async fn loop_over Result>( { app.record(Tone::Muted, shape); } - app.composer.set(&format!("/config {key} ")); + // **A machine-written key is not offered + // for typing at all, and prefilling it was + // a route straight past its own guard.** + // `manage::config_value` refuses a + // `Kind::Machine` key by name, but the + // shorthand `/config ` never + // consults the kind — so a composer + // prefilled with `/config prices.as_of ` + // led the operator to the one door that + // would write it. The row says what the + // key is and offers the act beside it. + if io_cli::configure::kind_of(&key) + != Some(io_cli::configure::Kind::Machine) + { + app.composer.set(&format!("/config {key} ")); + } } } } @@ -3539,7 +3627,8 @@ async fn loop_over Result>( // everywhere: an empty section is not an error. app.record( Tone::Muted, - "no MCP servers are configured; `/config` writes them", + "no MCP servers are configured; `/mcp add -- ` adds one, \ + or `/mcp add --url ` for an HTTP server", ); } else { picker = Some(( @@ -3951,8 +4040,11 @@ async fn loop_over Result>( let settings = io_cli::configure::settings(&config); let mut paths: Vec = settings.iter().map(|s| s.path.clone()).collect(); let mut rows = io_cli::configure::rows(&settings); - // **Last, because it is the one row that acts.** Every other - // row names a setting and puts it in the composer; a reader + // **Last, because it is the one row that acts on its own.** + // Every other row names a setting and descends into its + // values — since 0.28.0 it is only the four keys no menu can + // hold that still reach the composer, and this row reaches + // neither. A reader // scanning for `policy.defaults.write` should not have to step // over something that does work on the way there. rows.push(io_cli::configure::refresh_row(&io_cli::configure::setting( @@ -4050,6 +4142,27 @@ async fn loop_over Result>( }, } } + // **A machine-written key is refused here too, because this is the + // door the guard was missing.** `manage::config_value` refuses a + // `Kind::Machine` key by name, so `io config set prices.as_of …` + // has never been able to write one — but the shorthand reaches + // `write_where` without consulting the kind at all, and two doors + // to one key that disagree about whether it may be typed is the + // asymmetry this release exists to remove. The act is offered + // rather than the refusal being left bare: the date is written by + // re-reading the catalogue, and that row is on `/config`. + Action::Config(Some((key, _))) + if io_cli::configure::kind_of(&key) + == Some(io_cli::configure::Kind::Machine) => + { + app.record( + Tone::Refused, + format!( + "{key} is written by the price refresh rather than typed; the last row \ + of `/config` re-reads the catalogue and dates it" + ), + ); + } Action::Config(Some((key, value))) => { picker = Some(write_where(session.root(), key, value)); } @@ -6911,6 +7024,9 @@ enum Pick { /// it is reading a catalogue for. Read off the row the operator chose /// rather than re-derived, which is the same rule `at` follows. kind: String, + /// Where the "take its key out of the file" row sits, or `usize::MAX` + /// where the entry carries no written key and the row was not drawn. + credential_at: usize, /// Where the "change the model" and "remove" rows sit, recorded by the /// code that built them. /// diff --git a/tests/commands.rs b/tests/commands.rs index 8d34b6c..d95df80 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -332,6 +332,88 @@ fn f12_resume_says_it_answers_a_parked_run_rather_than_merely_reopening_a_sessio ); } +/// **A question is never a write, and this shipped as one until it was caught.** +/// +/// `/config get run.max_steps` fell through to the `/config ` +/// shorthand and was read as the key `get` with the value `run.max_steps`, so a +/// question wrote a key called `get` into the operator's file; `/config list` was +/// read as a question about a key named `list`. Found by a reviewer reading the +/// guard against the comment above it, which claimed both verbs reached +/// `manage::parse` when the guard matched only `set` and `unset`. +/// +/// Sabotage: drop `get` and `list` from their guards — under which this fails on +/// the `Action` a question produces, which is the shape of the write it would +/// have become. +#[test] +fn o2_a_config_question_is_answered_and_never_written() { + use io_cli::commands::Action; + + assert_eq!( + commands::parse("config get run.max_steps", &defaults(), &DARK), + Action::Config(Some(("run.max_steps".to_string(), String::new()))), + "`/config get ` must be the question this surface has always answered" + ); + assert_eq!( + commands::parse("config list", &defaults(), &DARK), + Action::Config(None), + "`/config list` must open the panel, not ask about a key named `list`" + ); + // A bare `get` is the panel rather than a question about an empty key. + assert_eq!( + commands::parse("config get", &defaults(), &DARK), + Action::Config(None) + ); + // And the two verbs that WRITE do go to the one parse. + assert!( + matches!( + commands::parse("config set run.max_steps 30", &defaults(), &DARK), + Action::Manage(_) + ), + "`/config set` must reach the same parse `io config set` reaches" + ); + assert!(matches!( + commands::parse("config unset app.io-cli.plain", &defaults(), &DARK), + Action::Manage(_) + )); + // The shorthand this surface has always had is untouched. + assert_eq!( + commands::parse("config run.max_steps 30", &defaults(), &DARK), + Action::Config(Some(("run.max_steps".to_string(), "30".to_string()))), + ); +} + +/// The write verbs of `/mcp` and `/plugin` reach the one parse; the bare word +/// opens the panel. +#[test] +fn o2_the_management_write_verbs_reach_the_shared_parse() { + use io_cli::commands::Action; + + for line in [ + "mcp add semlith -- semlith --store /tmp/.semlith mcp", + "mcp edit semlith --timeout-secs 30", + "mcp remove semlith", + "plugin add ./bundles/rust-review", + "plugin remove ./bundles/rust-review", + ] { + assert!( + matches!(commands::parse(line, &defaults(), &DARK), Action::Manage(_)), + "`/{line}` must reach `manage::parse`, or the slash form and `io …` can disagree" + ); + } + // A bare word is the panel, and `list` is the panel too: in a session the + // answer to "list" is the surface that draws it. + assert_eq!(commands::parse("mcp", &defaults(), &DARK), Action::Mcp); + assert_eq!(commands::parse("mcp list", &defaults(), &DARK), Action::Mcp); + assert_eq!( + commands::parse("plugin", &defaults(), &DARK), + Action::Plugin + ); + assert_eq!( + commands::parse("plugin list", &defaults(), &DARK), + Action::Plugin + ); +} + /// **The palette did not grow, and no group was re-filed.** /// /// The other half of the assertion above, and the reason it is worth its own From f1163549ab11dcb437ffde560a3680fd8df0aa75 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 15:34:53 +0530 Subject: [PATCH 14/15] fix: the model pickers offered one vendor's catalogue as another's --- src/main.rs | 60 ++++++++++++---- tests/manage.rs | 175 ++++++++++++++++++++++++++++++++++++++++++--- tests/plugins.rs | 31 +++++++- tests/preflight.rs | 51 +++++++++++++ tests/providers.rs | 93 ++++++++++++++++++++++++ 5 files changed, 386 insertions(+), 24 deletions(-) diff --git a/src/main.rs b/src/main.rs index 42e252b..534fb06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2326,11 +2326,7 @@ async fn loop_over Result>( // edit — which is what F10 actually asks for. app.record(Tone::Muted, "reading the model catalogue…".to_string()); paint(screen, &mut app)?; - let models: Vec = io_cli::verify::served(None) - .await - .into_iter() - .map(|model| model.id) - .collect(); + let models: Vec = catalogue_for(&preset).await; if models.is_empty() { // Not fatal, and deliberately so: a catalogue // that cannot be read is a reason to make the @@ -2545,17 +2541,15 @@ async fn loop_over Result>( if index == *model_at { app.record(Tone::Muted, "reading the model catalogue…".to_string()); paint(screen, &mut app)?; - let models: Vec = io_cli::verify::served(None) - .await - .into_iter() - .map(|model| model.id) - .collect(); + let models: Vec = catalogue_for(kind).await; if models.is_empty() { app.record( Tone::Muted, format!( - "no catalogue was served, so there is no list to \ - choose {label}'s model from" + "no catalogue names {kind}'s models, so there is no \ + list to choose {label}'s model from — a reference \ + catalogue cannot say what a self-hosted or \ + OpenAI-compatible endpoint serves" ), ); } else { @@ -7426,6 +7420,48 @@ fn manage_main( Ok(io_cli::exec::OK) } +/// The models `preset` actually serves, spelled the way `preset` spells them. +/// +/// **`verify::served` alone is the wrong list, and offering it raw was a defect +/// this release nearly shipped.** What that call returns is the *reference* +/// catalogue — OpenRouter's own view of the entire field, as `src/verify.rs:85-95` +/// says out loud — so its ids are namespaced (`anthropic/claude-…`). Writing one +/// of those into an `[[provider]]` entry of kind `anthropic` names a model +/// Anthropic's own API does not serve, and stores a price under a key no provider +/// call will ever match. `verify::named` exists for exactly this and the wizard +/// has always used it (`Progress::Catalogue`, twice); these two pickers were the +/// only readers of the catalogue that did not. +/// +/// Both arguments to `spec_from` are placeholders, and that is safe rather than +/// sloppy: `named` matches on the spec's *variant* and reads neither the model nor +/// the credential, no request is made from this spec, and `spec_from` discards the +/// key it is handed (`api_key: None`) precisely so a key never sits in a struct +/// longer than it must. The real spec, with the chosen model, is built for the +/// verification call afterwards. +/// +/// Empty for anything outside the three vendors `FromEnv` covers — a reference +/// list cannot say what a server it has never heard of serves, which is `named`'s +/// own answer for a `Compatible` endpoint. +async fn catalogue_for(preset: &str) -> Vec { + let which = match preset { + "openrouter" => io_cli::cli::FromEnv::OpenRouter, + "anthropic" => io_cli::cli::FromEnv::Anthropic, + "openai" => io_cli::cli::FromEnv::OpenAi, + _ => return Vec::new(), + }; + let Ok(shape) = io_cli::provider::spec_from( + which, + Some("placeholder".to_string()), + Some("placeholder".to_string()), + ) else { + return Vec::new(); + }; + ids(&io_cli::verify::named( + &shape, + io_cli::verify::served(None).await, + )) +} + /// The values one setting can take, as a picker, or `None` when it has to be typed. /// /// **The kind decides, and every option comes from somewhere that cannot go diff --git a/tests/manage.rs b/tests/manage.rs index 4b34ab0..e1c478e 100644 --- a/tests/manage.rs +++ b/tests/manage.rs @@ -19,6 +19,14 @@ fn argv(words: &[&str]) -> Vec { words.iter().map(|word| word.to_string()).collect() } +/// `Config::discover` reads `IO_CONFIG` at call time, so two tests setting it at +/// once would each see the other's file. Only the scope test needs it — every +/// other test here parses rather than plans. +fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + /// The server a request declares, for a test that is about the transport. fn server(request: &Request) -> &io_harness::McpServer { match request { @@ -39,6 +47,135 @@ fn written(root: &Path, request: &Request) -> String { io_cli::edit::apply("", &plan.edits).expect("the edits apply to an empty file") } +/// **F13, planned rather than parsed — the half that had no test at all.** +/// +/// Every other `plan` call in this file passes `scope: None` against an empty +/// temporary root, so only `decided_scope`'s `unwrap_or(Scope::User)` fallback +/// ever ran: replacing the whole function with `|_, _, _| Scope::User` left the +/// suite green, and `io config set x y --scope project` would have written the +/// user file. The parse carrying `Some(Scope::Local)` is asserted elsewhere and is +/// not the same claim. +/// +/// Sabotage: drop the inheritance lookup — under which the first arm fails, +/// because a key the project file decides would be written to the user file. +#[test] +fn f13_a_planned_config_write_inherits_the_deciding_file() { + let home = tempfile::tempdir().expect("a home outside the workspace"); + let root = tempfile::tempdir().expect("a workspace"); + // The user file is written OUTSIDE the workspace: a file inside it is + // project-scoped whatever variable names it. + std::fs::write(home.path().join("io.toml"), "[run]\nmax_steps = 10\n").unwrap(); + std::fs::write( + root.path().join("io.toml"), + "[app.io-cli]\ntheme = \"dark\"\n", + ) + .unwrap(); + + let guard = env_lock(); + std::env::set_var("IO_CONFIG", home.path().join("io.toml")); + + // A key the PROJECT file decides, with no `--scope`, goes back to it. + let inherited = manage::plan( + root.path(), + &manage::parse(&argv(&["config", "set", "app.io-cli.theme", "light"])).expect("it parses"), + ) + .expect("it plans") + .expect("a write"); + assert_eq!( + inherited.scope, + Scope::Project, + "a write with no `--scope` must go into the file already deciding the key, or a personal \ + file silently shadows a committed one" + ); + + // A key the USER file decides goes back to that. + let user = manage::plan( + root.path(), + &manage::parse(&argv(&["config", "set", "run.max_steps", "20"])).expect("it parses"), + ) + .expect("it plans") + .expect("a write"); + assert_eq!(user.scope, Scope::User); + + // An explicit `--scope` overrides the inheritance and moves the key. + let moved = manage::plan( + root.path(), + &manage::parse(&argv(&[ + "config", + "set", + "app.io-cli.theme", + "light", + "--scope", + "local", + ])) + .expect("it parses"), + ) + .expect("it plans") + .expect("a write"); + assert_eq!(moved.scope, Scope::Local); + + // And `unset` follows the same rule, since it is the same decision. + let unset = manage::plan( + root.path(), + &manage::parse(&argv(&["config", "unset", "app.io-cli.theme"])).expect("it parses"), + ) + .expect("it plans") + .expect("a write"); + assert_eq!(unset.scope, Scope::Project); + + std::env::remove_var("IO_CONFIG"); + drop(guard); +} + +/// `--flag=value` and `--flag value` are the same flag. +/// +/// Untested anywhere until now, while the module's own comment argued that a parse +/// taking only one form "would be a parse that works on one surface" — a claim +/// carried by prose alone. Deleting the inline branch makes `--url=https://x` a +/// flag literally named `url=https://x`, refused as unknown. +#[test] +fn f8_a_flag_may_be_written_with_an_equals_sign() { + let spaced = manage::parse(&argv(&[ + "mcp", + "add", + "linear", + "--url", + "https://mcp.linear.app/mcp", + ])) + .expect("the spaced form parses"); + let joined = manage::parse(&argv(&[ + "mcp", + "add", + "linear", + "--url=https://mcp.linear.app/mcp", + ])) + .expect("the joined form parses"); + assert_eq!(spaced, joined, "the two spellings must be one flag"); + + // A value that itself contains `=` keeps every character after the FIRST one, + // which is what an `Authorization: Bearer …` header depends on. + let request = manage::parse(&argv(&[ + "mcp", + "add", + "linear", + "--url", + "https://mcp.linear.app/mcp", + "--header=Authorization=Bearer abc=123", + ])) + .expect("a joined header parses"); + match &server(&request).transport { + io_harness::McpTransport::Http { headers, .. } => assert_eq!( + headers.get("Authorization").map(String::as_str), + Some("Bearer abc=123"), + "a header value must split at the first `=` and keep the rest" + ), + other => panic!("expected an HTTP server, got {other:?}"), + } + + // An empty flag name is refused rather than being read as a flag called "". + assert!(manage::parse(&argv(&["mcp", "add", "linear", "--=x"])).is_err()); +} + // --- F7: `--` ends io's own arguments ------------------------------------------ #[test] @@ -107,15 +244,23 @@ fn f7_the_slash_form_and_the_argv_form_are_one_token_slice() { argv(&["config", "set", "app.io-cli.gates.contains", "all green"]) ); + // **This compared `parse(X)` with `parse(X)` and was a tautology**, because + // the assertion above had already established that the two token slices are + // the same slice. Comparing the parse against the VALUE it must produce is + // what carries content: it fails if the tokeniser and the parser ever agree + // with each other about something wrong. let from_slash = manage::parse(&typed).expect("the slash form parses"); - let from_shell = manage::parse(&argv(&[ - "config", - "set", - "app.io-cli.gates.contains", - "all green", - ])) - .expect("the argv form parses"); - assert_eq!(from_slash, from_shell); + assert_eq!( + from_slash, + manage::Request::Config(manage::ConfigVerb::Set { + key: "app.io-cli.gates.contains".to_string(), + // The quotes are the shell's and the tokeniser's to remove; what + // reaches the file is the TOML source for the words themselves. + value: "\"all green\"".to_string(), + scope: None, + }), + "the slash form must parse to the request the argv form does, and to this one" + ); } // --- F8: the transport is decided by the form ---------------------------------- @@ -183,7 +328,19 @@ fn f8_neither_form_is_refused_with_both_shapes_shown() { let refusal = manage::parse(&argv(&["mcp", "add", "somewhere"])).expect_err("no form was given"); assert!(refusal.contains("--url"), "{refusal}"); - assert!(refusal.contains("--"), "{refusal}"); + // **`contains("--")` was here and could never fail**, because `"--url"` + // contains it — so a refusal that stopped teaching the stdio form entirely + // would have kept this test green while the criterion it serves says *both* + // shapes are shown. The needle has to be the `--` that stands alone. + // Taken from the refusal's real bytes and starting after the markup: the + // sentence writes ``-- `` inside backticks, so a needle of `"--"` + // split on whitespace never matches the token it is aiming at. That is the + // markup-inside-the-needle trap, and it is how a checker ends up checking + // nothing. + assert!( + refusal.contains("-- "), + "the refusal must show the stdio form as its own shape, not only `--url`: {refusal}" + ); assert!(refusal.contains("command"), "{refusal}"); assert!(refusal.contains("somewhere"), "{refusal}"); } diff --git a/tests/plugins.rs b/tests/plugins.rs index cd93c0e..ace9374 100644 --- a/tests/plugins.rs +++ b/tests/plugins.rs @@ -1005,13 +1005,38 @@ fn f11_the_candidate_order_is_stable() { bundle(&root, "alpha", MINIMAL); bundle(&root, "bundles/rust-review", MINIMAL); - assert_eq!(pluginview::candidates(&root), pluginview::candidates(&root)); - // Nearest first: depth leads, then the path. + // **`candidates(&root) == candidates(&root)` was here and asserted nothing**: + // the same pure call twice, over a function that ends in a sort. It could not + // fail, and "the order is stable" is not what it was checking. The order is a + // property of the sort key, so the sort key is what has to be pinned — both + // halves of it, because dropping the path tiebreak leaves the depth half + // sorted and lets same-depth rows follow whatever order `read_dir` happened + // to return on that machine. let found = pluginview::candidates(&root); - let depths: Vec = found.iter().map(|p| p.components().count()).collect(); + let depths: Vec = found.iter().map(|path| path.components().count()).collect(); let mut sorted = depths.clone(); sorted.sort_unstable(); assert_eq!(depths, sorted, "shallower bundles come first: {found:?}"); + + // The tiebreak: `alpha` and `zeta` sit at the same depth, so only the path + // half of the key can order them, and it must order them the same way twice. + let same_depth: Vec = found + .iter() + .filter(|path| path.components().count() == found[0].components().count()) + .map(|path| { + path.file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + }) + .collect(); + let mut expected = same_depth.clone(); + expected.sort(); + assert_eq!( + same_depth, expected, + "two bundles at one depth must be ordered by their path, or the row an operator picked \ + yesterday is somewhere else today: {found:?}" + ); } /// A bundle below the root is written relative, so a committed `io.toml` works for diff --git a/tests/preflight.rs b/tests/preflight.rs index 40656b4..d9a005b 100644 --- a/tests/preflight.rs +++ b/tests/preflight.rs @@ -482,3 +482,54 @@ fn the_report_is_about_the_server_it_was_given() { preflight::line(&p), ); } + +/// **The `None`-is-not-permission rule, asserted where it is actually decided.** +/// +/// The library test above performs `config.policy().unwrap_or_default()` itself, +/// so it proves what `Policy::default()` does and nothing about what the callers +/// do. The real decision is in `src/main.rs`, which nothing under `tests/` can +/// link — so the criterion's own sabotage, "read the `None` as permissive", had no +/// executable site. That is the same gap `f14_the_headless_listing_prints_the_deciding_file` +/// was written to close, and finding it here is why the audit of these tests was +/// worth running. +/// +/// It is the fail-open direction. `Config::policy()` answers `None` for a file +/// with no `[policy]` section, and reading that as "nothing denies anything" makes +/// the preflight report *permitted* for every server the runtime will refuse — +/// the one thing this module's docs say it exists not to do. +/// +/// Sabotage: change either call site to `unwrap_or_else(Policy::permissive)` — +/// under which this fails and names the shape it found. +#[test] +fn f9_no_caller_reads_an_absent_policy_section_as_permission() { + let driver = std::fs::read_to_string("src/main.rs") + .expect("the driver is beside the tests") + .replace("\r\n", "\n"); + + // Counted rather than spot-checked: a fourth caller added later is how this + // comes back. + let defaulted = driver.matches("policy().unwrap_or_default()").count(); + assert!( + defaulted >= 2, + "expected the preflight's callers to fall back to `Policy::default()`; found {defaulted}" + ); + for forbidden in [ + "policy().unwrap_or_else(Policy::permissive)", + "policy().unwrap_or_else(io_harness::Policy::permissive)", + "policy().unwrap_or(Policy::permissive())", + ] { + assert!( + !driver.contains(forbidden), + "the driver reads an absent `[policy]` section as permission (`{forbidden}`), so the \ + preflight would report permitted for servers the runtime refuses" + ); + } + + // And the interactive caller answers from the merged policy rather than the + // file's alone — one built on the file would refuse a server this very + // session has already been told to allow. + assert!( + driver.contains("approval::session_policy("), + "the interactive preflight must answer from the merged policy" + ); +} diff --git a/tests/providers.rs b/tests/providers.rs index 51f49c9..e286a03 100644 --- a/tests/providers.rs +++ b/tests/providers.rs @@ -907,6 +907,99 @@ fn f10_the_default_offered_is_the_variable_the_endpoint_already_reads() { std::env::remove_var(var); } +/// **The model pickers narrow the reference catalogue, and offering it raw was a +/// HIGH defect found by the adversarial review.** +/// +/// `verify::served(None)` returns OpenRouter's own view of the entire field, with +/// namespaced ids (`anthropic/claude-…`). Writing one of those into an +/// `[[provider]]` entry of kind `anthropic` names a model that provider does not +/// serve, and keys a price to something no provider call will match. +/// `verify::named` is what narrows and re-spells it; the wizard has always called +/// it and the two new pickers did not. +/// +/// Asserted against the driver as text because the pickers live in `src/main.rs`, +/// which nothing under `tests/` can link. The library half of the claim — that +/// `named` really does re-spell — is asserted directly below it. +/// +/// Sabotage: call `verify::served` from a picker without `verify::named` — under +/// which this fails on the call count, which is the shape of the bug. +#[test] +fn f10_every_reader_of_the_catalogue_narrows_it_to_the_provider() { + let driver = std::fs::read_to_string("src/main.rs") + .expect("the driver is beside the tests") + .replace("\r\n", "\n"); + + // **`served(None)` and not every `served`.** A read passing a `source` is the + // price refresh, and narrowing THAT is the wrong thing: an operator who set + // `app.io-cli.prices.source_url` has pointed io-cli at their own endpoint's + // catalogue and every row of it is theirs — `src/verify.rs:85-95` records that + // the filter was once applied there and made the key inert. So the rule is + // about reads of the *reference* list, which is what `None` asks for. + let reference = driver.matches("verify::served(None)").count(); + let named = driver.matches("verify::named(").count(); + assert!( + reference > 0, + "the driver reads no reference catalogue at all, which cannot be right" + ); + assert_eq!( + reference, named, + "{reference} read(s) of the reference catalogue and {named} call(s) to `verify::named`: \ + a reference read without narrowing offers one vendor's namespaced ids as another \ + vendor's models" + ); + + // And the two pickers go through the one helper rather than reading it + // themselves, so there is a single place the pairing can be got wrong. + assert!( + driver.contains("async fn catalogue_for("), + "the pickers should share one narrowing helper" + ); +} + +/// The library half: `named` really does re-spell, so the ids a picker offers are +/// the ids the provider answers to. +#[test] +fn f10_the_reference_catalogue_is_respelled_for_the_provider_it_is_offered_for() { + let reference = vec![ + io_harness::ModelInfo { + id: "anthropic/claude-sonnet-4".to_string(), + ..Default::default() + }, + io_harness::ModelInfo { + id: "openai/gpt-5".to_string(), + ..Default::default() + }, + ]; + + // OpenRouter's own catalogue is not a reference to it — it is the provider + // speaking for itself, so the namespaced ids stay. + let via_openrouter = io_cli::verify::named( + &io_harness::ProviderSpec::OpenRouter { + model: "x".to_string(), + api_key: None, + }, + reference.clone(), + ); + assert!(via_openrouter + .iter() + .any(|m| m.id == "anthropic/claude-sonnet-4")); + + // Anthropic answers to the bare name and to nothing else. + let via_anthropic = io_cli::verify::named( + &io_harness::ProviderSpec::Anthropic { + model: "x".to_string(), + api_key: None, + }, + reference, + ); + let ids: Vec<&str> = via_anthropic.iter().map(|m| m.id.as_str()).collect(); + assert_eq!( + ids, + vec!["claude-sonnet-4"], + "the prefix must be stripped, and another vendor's models must not be offered" + ); +} + /// F10's ordering, asserted against the driver as text. /// /// **The one gate that has to read `src/main.rs`, and it reads it because nothing From 210841e3213ccbabe2fe0798fd6a1ba3473a0ede Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 29 Aug 2026 15:39:01 +0530 Subject: [PATCH 15/15] test(edit): the byte arithmetic of unset, at four edges --- tests/edit.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/edit.rs b/tests/edit.rs index d56a1e6..0f85688 100644 --- a/tests/edit.rs +++ b/tests/edit.rs @@ -1085,3 +1085,98 @@ fn f1_a_whole_section_is_written_once_and_refused_when_it_is_already_there() { "the refusal does not say what to do instead: {error}" ); } + +// The adversarial pass over `Edit::unset`'s byte arithmetic, written as cases +// rather than as a reading. One of the two review agents for this release died to +// a rate limit before it reached this module, so these stand in for it — a +// self-review finds different things from an independent one and is not a +// substitute, but an unrun gate is worse than a narrower one. + +/// A CRLF file keeps its CRLF, and the cut does not strand a lone `\r`. +/// +/// `Edit::unset` runs forward to the first `\n` at or after the value's end. On a +/// Windows checkout that newline is preceded by `\r`, which belongs to the line +/// being removed — a cut that stopped at the `\r` would leave it behind and put a +/// bare carriage return in the middle of the next key's line. +#[test] +fn unset_takes_the_whole_line_on_a_crlf_file() { + let text = "[run]\r\nmax_steps = 8\r\nmax_tokens = 500\r\n"; + let after = io_cli::edit::apply(text, &[io_cli::edit::Edit::unset("run.max_steps")]) + .expect("the unset applies"); + + assert_eq!(after, "[run]\r\nmax_tokens = 500\r\n"); + assert!( + !after.contains("\r\r") && !after.contains("\n\r\n"), + "a stray carriage return was left behind: {after:?}" + ); + let config = io_harness::config::Config::from_toml(&after).expect("it still parses"); + assert!(io_cli::edit::value_at(&after, "run.max_steps").is_none()); + let _ = config; +} + +/// The last key of a file with no trailing newline. +/// +/// The forward scan finds no `\n` and falls back to `text.len()`. If that fallback +/// were off by one the splice would panic on a range past the end, and it is the +/// one input where the fallback is taken at all. +#[test] +fn unset_removes_the_last_key_of_a_file_that_does_not_end_in_a_newline() { + let text = "[run]\nmax_steps = 8\nmax_tokens = 500"; + let after = io_cli::edit::apply(text, &[io_cli::edit::Edit::unset("run.max_tokens")]) + .expect("the unset applies"); + + assert_eq!(after, "[run]\nmax_steps = 8\n"); + assert!(io_cli::edit::value_at(&after, "run.max_tokens").is_none()); + assert_eq!( + io_cli::edit::value_at(&after, "run.max_steps").as_deref(), + Some("8") + ); +} + +/// A value containing the bytes of a section header does not confuse the cut. +/// +/// The scan works from the value's own span, so a `#` or a `[run]` inside a string +/// is just text. Asserted because a line-oriented splicer that searched for those +/// markers instead would cut in the wrong place, and this module is line-oriented. +#[test] +fn unset_is_not_confused_by_a_value_that_looks_like_syntax() { + let text = "[app.io-cli.gates]\n\ + contains = \"[run] # not a header\"\n\ + rubric = \"keep me\"\n"; + let after = io_cli::edit::apply( + text, + &[io_cli::edit::Edit::unset("app.io-cli.gates.contains")], + ) + .expect("the unset applies"); + + assert_eq!(after, "[app.io-cli.gates]\nrubric = \"keep me\"\n"); + assert_eq!( + io_cli::edit::value_at(&after, "app.io-cli.gates.rubric").as_deref(), + Some("\"keep me\""), + "the sibling was disturbed by a value that contained a header" + ); +} + +/// Unsetting the only key of a section leaves the section standing and empty. +/// +/// Deliberate rather than incidental: an empty `[run]` is legal TOML and still +/// says the operator meant to configure the section. Removing the header would be +/// `Edit::remove`'s job, and doing it here is the destructive ambiguity the two +/// verbs are kept apart to prevent. +#[test] +fn unset_of_the_only_key_leaves_its_section_standing() { + let text = "[run]\nmax_steps = 8\n\n[memory]\nmax_entries = 5\n"; + let after = io_cli::edit::apply(text, &[io_cli::edit::Edit::unset("run.max_steps")]) + .expect("the unset applies"); + + assert!( + after.contains("[run]"), + "the header was taken too: {after:?}" + ); + assert_eq!( + io_cli::edit::value_at(&after, "memory.max_entries").as_deref(), + Some("5"), + "the following section was disturbed" + ); + io_harness::config::Config::from_toml(&after).expect("an empty section still parses"); +}