From 69e9369baad09f1b791cde6c002943b09388cb78 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Mon, 7 Sep 2026 08:00:38 +0530 Subject: [PATCH 1/9] chore(deps): pin io-harness 0.83.0 and re-anchor every citation to it The pin carries two things this release needs and neither is io-cli's to fix: `sandbox.allow_network = true` now reaches the macOS backend on a proxied run, which is what O1 verifies end to end, and `git_log` on a repository with no commits answers in its own words rather than with the error git also returns for "not a git repository". Every `io-harness-0.82.0/:` citation under src/ and tests/ was verified against the 0.83.0 tree rather than having its version string rewritten. Four line numbers moved: the three `[run]` defaults in contract.rs (755,773,789 -> 780,798,814) and `withheld_sentence` in run/prompts.rs (1381 -> 1418). Nothing cited is gone and no cited item changed meaning. --- Cargo.lock | 6 +++--- Cargo.toml | 4 ++-- src/adapt.rs | 12 ++++++------ src/commands.rs | 4 ++-- src/context.rs | 2 +- src/contract.rs | 6 +++--- src/exec.rs | 2 +- src/home.rs | 2 +- src/import.rs | 2 +- src/manage.rs | 2 +- src/memory.rs | 4 ++-- src/reload.rs | 2 +- tests/adapt.rs | 2 +- tests/context.rs | 2 +- tests/contract.rs | 2 +- tests/hooks.rs | 2 +- tests/instructions.rs | 2 +- tests/live.rs | 2 +- tests/reload.rs | 2 +- 19 files changed, 31 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e852bd2..7aa4130 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1850,7 +1850,7 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.39.0" +version = "0.40.0" dependencies = [ "clap", "crossterm", @@ -1868,9 +1868,9 @@ dependencies = [ [[package]] name = "io-harness" -version = "0.82.0" +version = "0.83.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1af4df08e2107814bd31166464852840953335730ed35179e1d136a290a6ac6d" +checksum = "6954efd7c5789c6003496d83d32a8da4b24c26f0ddbd82a99eb7cca7491618b8" dependencies = [ "base64 0.23.1", "calamine", diff --git a/Cargo.toml b/Cargo.toml index fa2b370..ada3a1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.39.0" +version = "0.40.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 @@ -73,7 +73,7 @@ path = "src/lib.rs" # claim is verified against `cargo tree --depth 1` with all six features on # rather than believed from the harness's manifest — see `tests/dependencies.rs`, # which asserts the ten names in both directions. -io-harness = { version = "0.82", features = [ +io-harness = { version = "0.83", features = [ "media", "browser", "documents", diff --git a/src/adapt.rs b/src/adapt.rs index 512bb8e..4a7baf0 100644 --- a/src/adapt.rs +++ b/src/adapt.rs @@ -70,7 +70,7 @@ //! `self.root.join(d)` and `Path::join` of an absolute argument discards the base. //! io-harness 0.74.0 refuses that spelling for `skills` and for `templates` in //! **every** scope, `Scope::User` included -//! (`io-harness-0.82.0/src/plugin.rs:1097`), and the reason is the one this +//! (`io-harness-0.83.0/src/plugin.rs:1097`), and the reason is the one this //! product would give: every `*.md` under such a directory is read into the //! model's system prompt on every turn, so a manifest contributes a directory it //! *ships* rather than one it points at somewhere else on the machine. Copying is @@ -377,7 +377,7 @@ pub const SKILLS_DIR: &str = "skills"; /// io-harness calls the same thing a **template**, and the two formats are the /// same file: markdown, optional frontmatter, `$ARGUMENTS` for what the operator /// typed. `Templates::discover` takes every `*.md` in a directory -/// (`io-harness-0.82.0/src/template.rs:279`), so what is translated is the +/// (`io-harness-0.83.0/src/template.rs:279`), so what is translated is the /// directory and never the files in it — the bytes are copied verbatim into /// [`TEMPLATES_DIR`] and nothing in them is rewritten. pub const COMMANDS_DIR: &str = "commands"; @@ -578,7 +578,7 @@ pub fn at(root: &Path, owner: &str, repo: &str, name: &str) -> PathBuf { /// /// **The two directories are copied into `into` and named relative to it.** /// io-harness 0.74.0 refuses an absolute or a `..`-carrying `skills` or -/// `templates` in every scope (`io-harness-0.82.0/src/plugin.rs:1097`), because +/// `templates` in every scope (`io-harness-0.83.0/src/plugin.rs:1097`), because /// every `*.md` under one is read into the model's system prompt — so a bundle /// contributes a directory it ships. An adapter comes to ship one by copying it, /// and [`Adapter::copied`] reports which moved. The copy refuses a symbolic link @@ -714,7 +714,7 @@ pub fn generate(bundle: &Path, name: &str, into: &Path) -> Result Option { /// adapted, so expanding it reads nothing of the host — it writes down a path io /// already knew. `${env:…}`, `${file:…}`, `${cmd:…}` and every shell variable a /// bundle writes are the opposite: io-harness refuses each of them inside a -/// manifest in every scope (`io-harness-0.82.0/src/plugin.rs:977`), because a +/// manifest in every scope (`io-harness-0.83.0/src/plugin.rs:977`), because a /// bundle is a third party's directory even when the file naming it is the /// operator's own. /// @@ -1252,7 +1252,7 @@ fn unquoted(said: &str) -> &str { /// /// **A transport io-harness does not speak is a refusal naming the server**, not /// a server quietly left out. io-harness speaks stdio and streamable HTTP -/// (`io-harness-0.82.0/src/mcp.rs:301`), so a bundle declaring `sse` is declaring +/// (`io-harness-0.83.0/src/mcp.rs:301`), so a bundle declaring `sse` is declaring /// a capability no adapter can deliver — and a manifest carrying one fewer server /// than the bundle does is exactly the silent absence this module's docs exist to /// end. The whole generation stops, because a bundle is installed for what it diff --git a/src/commands.rs b/src/commands.rs index 990b915..528ad38 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1489,7 +1489,7 @@ pub const READ_MARK: &str = "+"; /// The case this page exists for. A file that is there and is not read looks, /// from everywhere else in this product, exactly like one that is read: nothing /// warns, and a missing or skipped instruction file is passed over in silence -/// (`read_instructions`, `io-harness-0.82.0/src/config.rs:3120`). +/// (`read_instructions`, `io-harness-0.83.0/src/config.rs:3120`). pub const UNREAD_MARK: &str = "-"; /// What one row of the memory page stands for. @@ -1705,7 +1705,7 @@ pub fn memory_notes(view: &crate::recall::View, glyphs: &crate::glyphs::Glyphs) /// writing: a file that exists and is not read. It names what would make it /// read rather than only reporting the fact, because `[instructions] files` /// **replaces** the default list rather than adding to it -/// (`DEFAULT_INSTRUCTIONS`, `io-harness-0.82.0/src/config.rs:190`, read at +/// (`DEFAULT_INSTRUCTIONS`, `io-harness-0.83.0/src/config.rs:190`, read at /// `:3129`) and there is nothing on any /// other surface to pull on. pub fn instruction_said( diff --git a/src/context.rs b/src/context.rs index d482881..3a69737 100644 --- a/src/context.rs +++ b/src/context.rs @@ -128,7 +128,7 @@ pub const SKILLS_HEAD: &str = "Skills available to you"; /// **The directive is glued to the last catalogue line with no newline between /// them, so a line scan cannot see the boundary.** `compose` builds the prompt as /// `with_skill_catalog(..)` and then `out.push_str(&directive)` -/// (io-harness-0.82.0/src/run/prompts.rs:73-76); `Skills::catalog()` ends with its +/// (io-harness-0.83.0/src/run/prompts.rs:73-76); `Skills::catalog()` ends with its /// last `- name: description` line carrying **no trailing newline** /// (src/skills.rs:554-565); and `planning_directive` returns a string beginning /// with a space (src/run/gate.rs:322-326). So with a plan gate registered — which diff --git a/src/contract.rs b/src/contract.rs index 88852f2..e334a30 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -527,7 +527,7 @@ pub fn buying(contract: TaskContract, effort: Option) -> Tas /// sits ahead of a cache breakpoint and removing a definition would save its /// tokens once and pay a cache *write* on every later turn (`src/tools/mod.rs:40`). /// A mask in fact **adds** a sentence to the user prompt naming the withheld tools -/// (`io-harness-0.82.0/src/run/prompts.rs:1381`, `withheld_sentence`), placed after the observations +/// (`io-harness-0.83.0/src/run/prompts.rs:1418`, `withheld_sentence`), placed after the observations /// precisely so it costs no cache entry. A turn that withholds three tools is /// marginally more expensive than the same turn without the mask, not less. #[must_use] @@ -779,7 +779,7 @@ pub fn skills_dir(config: &Config, capabilities: &Capabilities, root: PathBuf) - /// **The existence test is not caution, it is the whole of what makes this /// default safe.** `Skills::discover` does not return early on a directory that /// is not there — it returns `Error::Config("skills directory … does not exist")` -/// (`io-harness-0.82.0/src/skills.rs`), and `TaskContract::discover_skills` +/// (`io-harness-0.83.0/src/skills.rs`), and `TaskContract::discover_skills` /// propagates it from `run.rs` at run start, before the first completion. A /// contract that named this directory unconditionally would therefore fail every /// turn of every operator who has never made one, which is almost all of them. @@ -804,7 +804,7 @@ fn default_skills() -> Option { /// /// **One expansion for two keys, applied after both have had their say.** /// io-harness substitutes `${env:…}`, `${file:…}` and `${cmd:…}` and nothing else -/// (`substitute`, `io-harness-0.82.0/src/config.rs:3159` — there is no tilde +/// (`substitute`, `io-harness-0.83.0/src/config.rs:3159` — there is no tilde /// branch anywhere in it, and 0.71.0 narrowed the forms rather than widening /// them: a plugin manifest now refuses all three), so a `~` an operator wrote in /// `[run] skills` or `[app.io-cli] skills` diff --git a/src/exec.rs b/src/exec.rs index 61d8e6e..d6b88b6 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -1763,7 +1763,7 @@ impl WithProvider for Resuming { // other pause kind has both forms — `resume_tree_with_answer` beside // `resume_with_answer`, `resume_tree_with_plan_decision` beside its flat // one, `resume_tree_with_decision` beside `resume_with_decision` - // (`io-harness-0.82.0/src/run.rs:1824`, `:2158`, `:3234`). Recovery has + // (`io-harness-0.83.0/src/run.rs:1824`, `:2158`, `:3234`). Recovery has // `resume_with_recovery_observed` (`:2639`) and nothing tree-aware, so it // is the one pause a contained run cannot be resumed from. Not an oversight // this crate can route around: a fleet's shared ceiling lives in the tree diff --git a/src/home.rs b/src/home.rs index 71a7ac7..69fe6ef 100644 --- a/src/home.rs +++ b/src/home.rs @@ -9,7 +9,7 @@ //! This module names one. It is deliberately **not** a second configuration //! system: io-harness resolves `$IO_CONFIG`, then `$IO_CONFIG_HOME`, then the //! platform's own place, reading the environment at call time -//! (`io-harness-0.82.0/src/config.rs:2411`, the Windows branch at `:2429`), and +//! (`io-harness-0.83.0/src/config.rs:2411`, the Windows branch at `:2429`), and //! there is no caller-supplied home //! anywhere in `Config`'s public surface — no `discover_in`, no builder. So the //! one lever is `$IO_CONFIG_HOME`, set once, before the first diff --git a/src/import.rs b/src/import.rs index 103b303..b8a183c 100644 --- a/src/import.rs +++ b/src/import.rs @@ -30,7 +30,7 @@ //! decision="allow")` and Claude's `permissions.ask` says `Bash(cargo yank *)` — //! both of which match a *command line*. io-harness's `Act::Exec` matches a //! **binary name and nothing else**; it has no argument matching at all -//! (`Act::Exec`, `io-harness-0.82.0/src/policy.rs:63`). So the nearest thing to a faithful +//! (`Act::Exec`, `io-harness-0.83.0/src/policy.rs:63`). So the nearest thing to a faithful //! import of `bun install` is a blanket allow on `bun`, which is a wider //! permission than the operator ever granted, written by a tool they were //! trusting to be careful. A boundary half-imported is worse than one left alone, diff --git a/src/manage.rs b/src/manage.rs index 2fe41b0..2b29b60 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -1441,7 +1441,7 @@ fn mcp_add(args: &Args) -> Result { // Built by hand rather than through `McpServer::stdio(…).with_args(…)`, // because `env` and `headers` have no builder at all and `with_args` is a - // silent no-op on an HTTP server (`io-harness-0.82.0/src/mcp.rs:439-447`: the + // silent no-op on an HTTP server (`io-harness-0.83.0/src/mcp.rs:439-447`: the // body writes only into the `Stdio` arm) — a constructor chain here would drop // the arguments of half the servers it was handed and say nothing. // Asked of the harness rather than written as literals, the way `servers::add` diff --git a/src/memory.rs b/src/memory.rs index 8cc1916..d60ee60 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -27,7 +27,7 @@ //! //! Worth knowing before a surface promises otherwise. `read_instructions` joins //! each name in `[instructions] files` to the **discovery root** -//! (`io-harness-0.82.0/src/config.rs:3120`), and the default list is exactly +//! (`io-harness-0.83.0/src/config.rs:3120`), and the default list is exactly //! `["AGENTS.md"]` (`DEFAULT_INSTRUCTIONS`, `config.rs:190`). So `AGENTS.md` is read //! by every project with no configuration at all; `AGENTS.local.md` is read only //! where a file names it; and `IO.md` is not reachable by a bare name at all, @@ -464,7 +464,7 @@ fn bullet(line: &str) -> Option { /// The order the three files are named in, and it is the order they reach the /// model in: `read_instructions` pushes one constraint per name in list order -/// (`read_instructions`, `io-harness-0.82.0/src/config.rs:3120`). +/// (`read_instructions`, `io-harness-0.83.0/src/config.rs:3120`). /// /// Project, then local, then user — widest audience first, so the operator's own /// standing note is the last thing said. It also matches the order the three diff --git a/src/reload.rs b/src/reload.rs index 0238806..de7b5f0 100644 --- a/src/reload.rs +++ b/src/reload.rs @@ -2,7 +2,7 @@ //! //! **io-harness takes the instruction files as a snapshot and never takes //! another.** `read_instructions` runs inside `Config::discover` -//! (`io-harness-0.82.0/src/config.rs:3120`), composes each named file into a +//! (`io-harness-0.83.0/src/config.rs:3120`), composes each named file into a //! constraint, and stores the result in a private `Config.instructions` field. //! There is no `Config::reload`, and `Config::with_profile` clones the field //! rather than going back to disk — so a `Config` is exactly as old as the call diff --git a/tests/adapt.rs b/tests/adapt.rs index 6255a5f..f8a42a6 100644 --- a/tests/adapt.rs +++ b/tests/adapt.rs @@ -510,7 +510,7 @@ fn loaded(into: &Path) -> Plugins { /// that io wrote until 0.35.0. The generator still runs and the fixture still /// builds — and `dropped()` carries the whole bundle, because io-harness 0.74.0 /// refuses an absolute `skills` or `templates` in every scope, `Scope::User` -/// included (`io-harness-0.82.0/src/plugin.rs:1097`): a manifest contributes a +/// included (`io-harness-0.83.0/src/plugin.rs:1097`): a manifest contributes a /// directory it ships rather than one it points at somewhere else on the machine, /// since every `*.md` under it is read into the model's system prompt. #[test] diff --git a/tests/context.rs b/tests/context.rs index 7d2c9d7..c4612b6 100644 --- a/tests/context.rs +++ b/tests/context.rs @@ -1006,7 +1006,7 @@ fn f9_allow_reports_a_no_op_and_a_clear_names_what_returned() { /// **The defect this replaces was the safety lever being silently inert.** /// io-harness keeps an unknown mask name rather than rejecting it, deliberately, /// so a mask stays portable across builds with different cargo features -/// (`io-harness-0.82.0/src/tools/mod.rs:55-58`). That means `mask_gate` matches on +/// (`io-harness-0.83.0/src/tools/mod.rs:55-58`). That means `mask_gate` matches on /// the exact string and a misspelling withholds nothing — while the operator was /// told "calling it will be refused before anything starts" and `/context` drew it /// on the withheld row. `/context withhold Docx_Write` and the file gets written. diff --git a/tests/contract.rs b/tests/contract.rs index 5354952..164eb72 100644 --- a/tests/contract.rs +++ b/tests/contract.rs @@ -1829,7 +1829,7 @@ fn f5_servers_in_both_scopes_are_merged_and_a_collision_is_named() { /// **F7 — a tilde is a home directory, never a directory named `~`.** /// /// io-harness substitutes `${env:…}` and `${file:…}` and nothing else — there is -/// no tilde branch anywhere in `io-harness-0.82.0/src/config.rs` — so a `~` an +/// no tilde branch anywhere in `io-harness-0.83.0/src/config.rs` — so a `~` an /// operator writes in `[run] skills` reaches `Skills::discover` verbatim and the /// harness looks inside a directory whose name is one character long. The /// operator's skills sit exactly where they said they would, and the session diff --git a/tests/hooks.rs b/tests/hooks.rs index 8dd00a0..84df107 100644 --- a/tests/hooks.rs +++ b/tests/hooks.rs @@ -410,7 +410,7 @@ fn a_hook_naming_an_event_that_does_not_exist_is_refused_rather_than_installed() // --------------------------------------------------------------------------- // // `io_harness::Hooks::new` creates every `append` path it is given, empty, as the -// value is constructed (`io-harness-0.82.0/src/hooks.rs`) — deliberately, so that +// value is constructed (`io-harness-0.83.0/src/hooks.rs`) — deliberately, so that // "the filter matched nothing" stays distinguishable from "the hook was never // installed". That makes *constructing* a `Hooks` a write to the filesystem, and // it resolves a relative `append` against the directory it was handed. Handed diff --git a/tests/instructions.rs b/tests/instructions.rs index 14d9de9..f7632ba 100644 --- a/tests/instructions.rs +++ b/tests/instructions.rs @@ -5,7 +5,7 @@ //! other half and `tests/memory.rs` covers it. This file covers the half that //! decides whether any of it reaches a model: `[instructions] files`, which is //! the only mechanism io-harness has for finding two of the three -//! (`io-harness-0.82.0/src/config.rs:190` — the automatic set is exactly +//! (`io-harness-0.83.0/src/config.rs:190` — the automatic set is exactly //! `["AGENTS.md"]`). //! //! Every test sets `IO_CONFIG_HOME`, because that is the only way to move the diff --git a/tests/live.rs b/tests/live.rs index 9ceb413..f17e3de 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -4084,7 +4084,7 @@ async fn live_f6_a_withheld_tool_is_refused_by_the_mask_and_says_so() { // working mask, which is the finding worth keeping: io-harness *announces* the // mask in the user prompt — "Unavailable this turn — these tools are listed // above but calling one is refused and starts nothing: write_file" - // (`io-harness-0.82.0/src/run/prompts.rs:1381`) — so a compliant model never attempts the call and + // (`io-harness-0.83.0/src/run/prompts.rs:1418`) — so a compliant model never attempts the call and // never produces the refusal. The run above said so in its own reasoning: "the // previous turns show write_file was refused, so I used a shell redirect // instead". That is the mask working at its best, not evidence of absence. diff --git a/tests/reload.rs b/tests/reload.rs index 452a139..681fb8c 100644 --- a/tests/reload.rs +++ b/tests/reload.rs @@ -79,7 +79,7 @@ fn workspace(io_toml: &str, agents: &str) -> (tempfile::TempDir, Configuration) /// Whether any composed instruction carries `line`. /// /// io-harness words each instruction file as `Project instructions from -/// `AGENTS.md`:\n` (`io-harness-0.82.0/src/config.rs:3143-3146`), so the +/// `AGENTS.md`:\n` (`io-harness-0.83.0/src/config.rs:3143-3146`), so the /// assertion is a containment and not an equality — this file is not the place /// that pins the harness's wording. fn instructs(held: &Configuration, line: &str) -> bool { From 04a6f6561fb923a0dc8180bba85889d1258b26f3 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Mon, 7 Sep 2026 08:00:54 +0530 Subject: [PATCH 2/9] feat(config): every documented [app.io-cli] key is settable from either door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirteen keys reached the fall-through that quotes any unrecognised `app.io-cli.*` word, so `io config set app.io-cli.reference_catalogue false` wrote the string "false" and io-harness — which reads [app.io-cli] as one opaque value — failed the whole section, reverting theme, keybindings, containment, gates and routing to defaults behind a single warning line. 0.38.1 fixed this for one key and the fix did not generalise, because nothing in the suite walked from the settings struct to the catalogue. The gate is that walk, and it was built before the fix so it produced the list rather than confirming one. It found a fourteenth key the analysis had missed: `BrowserConfig` also carries `binary`. That one is deliberately not offered — it names a program to execute, io-harness refuses a whole top-level [browser] section in a workspace file for exactly that reason, and it cannot apply that rule to a section it reads opaquely. `max_total_duration` needed a kind of its own. It deserializes as a `Duration`, so what the file must carry is `{ secs = N, nanos = 0 }` and no scalar an operator types is a value for it — a bare 60 fails exactly as the quoted "60" this release removes does. `Kind::Duration` takes the seconds and writes the table. Its origin is read through `.secs`, because `Config::origin` walks to a leaf and a duration is not one, so a value just written was reading back as unset. `browser.args` needed no new arm anywhere: it is the same act `gates.command` has been since 0.24.0. `configure::EXCLUDED` is the other half of the new gate — the leaves this surface does not offer, each with the sentence saying why, so being left out is a decision rather than a gap. --- docs/config.example.toml | 70 ++++++++++- src/configure.rs | 176 ++++++++++++++++++++++++++-- src/main.rs | 1 + tests/configure.rs | 242 ++++++++++++++++++++++++++++++++++++++- tests/docs.rs | 215 ++++++++++++++++++++++++++-------- 5 files changed, 640 insertions(+), 64 deletions(-) diff --git a/docs/config.example.toml b/docs/config.example.toml index 7680960..571ef15 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -600,6 +600,25 @@ plain = false # # conversational = false # +# true or false. Whether io may read the reference catalogue over the network to +# size a model's context window and price its tokens (0.39.0). +# +# true is the default and what an absent key means. false is the switch for an +# operator who wants no request io did not have to make — an air-gapped machine, a +# self-hosted endpoint the reference catalogue has never heard of, or a policy that +# says so. With it off, the surfaces that would have shown a window or a price say +# what they do not know rather than guessing. +# +# THIS KEY WAS UNSETTABLE FROM 0.39.0 UNTIL 0.40.0 and it is worth saying why, in +# the file that promises a worked example for every key. `io config set` quoted it, +# `CliSettings` could not read `"false"` back, and the refusal was not of that key: +# io-harness reads [app.io-cli] as one opaque value, so the whole section failed — +# theme, keybindings, containment, gates and routing all reverting to defaults +# behind a single warning line. The escape hatch for a network call was the one key +# you could not reach. +# +# reference_catalogue = false +# # How many steps one turn may take. # # DEPRECATED IN 0.14.0 AND REMOVED IN 0.16.0. It existed because the flat turn @@ -657,8 +676,11 @@ fleet = "ctrl+f" # well. Every one of those reaches every turn now, and this table means exactly # what its name says: the caps a fan-out runs under. # -# All four keys are required, because there is no safe default for somebody -# else's token ceiling. +# The first four keys are required, because there is no safe default for somebody +# else's token ceiling. The last two are optional and are written out here from +# 0.40.0, because five of these six were settable from `io config set` and named +# by no `/config` list at all — a key that can be spelled and cannot be found is a +# key an operator meets only by reading io-cli's source. # # max_total_agents refuses: the spawn that would cross it comes back to the # parent, which adapts. max_concurrent_agents throttles: a spawn past it waits @@ -666,11 +688,28 @@ fleet = "ctrl+f" # from the root, which is 0. max_total_tokens is the whole tree's, drawn down # together — tokens, because a provider reports tokens and never money. # +# max_total_duration is a WALL-CLOCK ceiling for the whole tree, measured from +# when the root run started — so it counts the tree's whole life, including time +# the process was down. It is written into the file as a table rather than a +# number, which is io-harness's `Duration` and not io-cli's choice: +# `io config set app.io-cli.containment.max_total_duration 3600` takes the seconds +# and writes the table for you, and a bare 3600 written by hand fails the whole +# [app.io-cli] section. +# +# max_total_cost IS RESERVED AND NOT ENFORCED, and io-harness says so itself: +# enforcing a money ceiling needs a price per token that a provider never reports, +# so any figure the harness compared against would be one it invented. It +# deserializes, so a file that sets it parses and the ceiling does nothing. The +# spend that IS enforced is max_total_tokens — convert your budget at your +# provider's rate and set that instead. +# # [app.io-cli.containment] # max_total_agents = 12 # max_concurrent_agents = 4 # max_depth = 2 # max_total_tokens = 200000 +# max_total_duration = { secs = 3600, nanos = 0 } +# max_total_cost = 5 # The three ceilings io-harness has no configuration key for. They are # TaskContract fields, so io-cli names them in its own section — and it applies @@ -744,9 +783,35 @@ fleet = "ctrl+f" # nobody typed, is decided by this session's policy. The status line shows the # last host and whether it was allowed or refused. # +# Every key of this table except `binary` is settable from `io config set` as of +# 0.40.0 — before it, all five reached the fall-through that quoted an +# unrecognised `app.io-cli.*` word, so `headless false` was written as the string +# "false" and the whole [app.io-cli] section stopped deserializing behind one +# warning line. +# +# `binary` is DELIBERATELY not offered at either `/config` door, and the reason is +# not that it is hard to type. It names a program to execute; io-harness refuses a +# whole top-level [browser] section in a project file for exactly that reason, and +# it cannot apply that rule here because [app.io-cli] is the section it reads as an +# opaque value. Write it by hand, in the file you chose, as below. +# +# binary the browser executable. ABSENT resolves it from io-harness's own +# ordered list of well-known names, which is documented — so you +# read which binary will be picked rather than running it to find +# out. +# args extra arguments, a list. Written from the shell as the remaining +# words: `io config set app.io-cli.browser.args --disable-gpu`. +# headless true or false. +# width/height the viewport, in whole pixels. +# timeout_secs a whole number of seconds for one navigation. +# # [app.io-cli.browser] # binary = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" +# args = ["--disable-gpu"] # headless = true +# width = 1280 +# height = 800 +# timeout_secs = 30 # Where the prices above came from (0.22.0). The rates themselves are io-harness's # and live in [prices]; this is the bookkeeping io-harness does not model, in the @@ -777,6 +842,7 @@ fleet = "ctrl+f" # # [app.io-cli.prices] # source_url = "https://openrouter.ai/api/v1/models" +# models = 417 # --- what "done" means for this repository (0.24.0) ------------------------- # diff --git a/src/configure.rs b/src/configure.rs index 3ea889b..88d1809 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -182,6 +182,38 @@ pub const CATALOGUE: &[&str] = &[ "app.io-cli.diff", "app.io-cli.glyphs", "app.io-cli.plain", + // Whether io asks a provider for its model catalogue at all. 0.39.0 shipped + // this as the way to turn that network call off and left it unsettable: the + // fall-through quoted it, `CliSettings` could not read `"false"` back, and the + // whole of `[app.io-cli]` failed behind one warning line. A key that is the + // documented escape hatch for a network call is the last key that may be + // unsettable, which is why it leads this release rather than closing it. + "app.io-cli.reference_catalogue", + // **The containment ceilings, all six.** Five of them were typed correctly by + // `kind_of` and named by no catalogue row, which is the asymmetry `kind_of`'s + // own doc says must not exist: a key that can be spelled and cannot be found + // is a key an operator meets only by reading this crate's source. + // + // `max_total_cost` is here and io-harness documents it as reserved and **not + // enforced** (`io-harness-0.83.0/src/containment.rs:97-107`). It is listed + // because it deserializes, so an operator who writes it has a file that + // parses and a ceiling that does nothing — and the row is where that can be + // said. `docs/config.example.toml` says it beside the example. + "app.io-cli.containment.max_total_agents", + "app.io-cli.containment.max_concurrent_agents", + "app.io-cli.containment.max_depth", + "app.io-cli.containment.max_total_tokens", + "app.io-cli.containment.max_total_cost", + "app.io-cli.containment.max_total_duration", + // **The browser, less its binary.** `[app.io-cli.browser]` is io-harness's own + // `BrowserConfig`, and every scalar of it is here except `binary` — see + // [`EXCLUDED`], which says why a program name is the one field of this table + // that is not offered at a shell door. + "app.io-cli.browser.headless", + "app.io-cli.browser.width", + "app.io-cli.browser.height", + "app.io-cli.browser.timeout_secs", + "app.io-cli.browser.args", // The three `TaskContract` ceilings io-harness gives no key of its own, so // io-cli names them here and `/config` is where an operator meets them. "app.io-cli.max_parallel_reads", @@ -195,6 +227,10 @@ pub const CATALOGUE: &[&str] = &[ // setting, and it is reached the way `/provider` and `/mcp` reach a list. "prices.as_of", "app.io-cli.prices.source_url", + // How many models the last catalogue read held. io-cli's own count and not + // io-harness's `[prices.models]` table, which is a list of model prices and is + // still deliberately not a row. + "app.io-cli.prices.models", // What "done" means here. Every key of the section and not a chosen few, // because the section refuses rather than defaults: exactly one of `command`, // `file` and `rubric` may be set, and an operator meeting three of the eight @@ -235,6 +271,59 @@ pub const CATALOGUE: &[&str] = &[ "app.io-cli.routing.downshift_under.model", ]; +/// The `[app.io-cli]` leaves this surface deliberately does not offer, each with +/// its reason. +/// +/// **A list cannot notice what is missing from it**, which is the whole reason +/// this constant exists. [`CATALOGUE`] is checked against the documentation and +/// the documentation against [`CATALOGUE`], and both directions were satisfied +/// while eight keys of `CliSettings` were reachable from neither — including the +/// one 0.39.0 shipped as the way to turn a network call off. So `tests/docs.rs` +/// now walks the *settings struct* and requires every scalar leaf of it to be in +/// one of these two lists. A key added to `CliSettings` and to neither fails the +/// build, which is what makes a fourteenth key impossible rather than unlikely. +/// +/// Being excluded is a decision with a sentence, never a gap. Each entry here is +/// still readable through [`settings`] and still editable as text in the file; what +/// it is not is a row offered at a value picker. +pub const EXCLUDED: &[(&str, &str)] = &[ + ( + "app.io-cli.keys", + "a map of action name to chord, not a setting with one value — `/keys` is where a \ + binding is chosen, and `app.io-cli.keys.accept 1` is a legitimate single-character \ + binding whose bare `1` would take the whole section down", + ), + ( + "app.io-cli.mcp", + "a list of servers, added and removed through `/mcp` and `io mcp`, which address an \ + entry by its content rather than by a row index", + ), + ( + "app.io-cli.lsp", + "a list of language servers, for the same reason as `mcp` and through the same door", + ), + ( + "app.io-cli.skills", + "a directory rather than a file, and the value picker's `File` kind offers the \ + workspace's files — a picker that offered files for a directory would be worse than \ + no picker at all", + ), + ( + "app.io-cli.browser.binary", + "it names a program to execute. io-harness refuses a whole `[browser]` section in a \ + workspace file for exactly that reason, and it cannot apply that rule here, because \ + `[app.io-cli]` is the section it reads as an opaque value — so offering a program \ + path at a shell door with `--scope project` would put a widening act in the one \ + section nothing widening-checks", + ), + ( + "app.io-cli.prices.source", + "written by the price refresh rather than typed, and unlike `prices.as_of` it names \ + where the table came from rather than when — a provenance an operator can overwrite \ + is a provenance that says nothing", + ), +]; + /// How a value for a key is obtained. /// /// **The kind says how a value is *obtained*, never what it means.** io-harness @@ -276,6 +365,16 @@ pub enum Kind { List, /// Text no menu can hold: a substring to look for, a rubric, a URL. Three keys. Text, + /// A number of seconds, written as the inline table `std::time::Duration` + /// deserializes from. + /// + /// Exactly one key — `app.io-cli.containment.max_total_duration` is + /// `Option` with no `#[serde(with)]` on it, so what serde reads is a + /// struct of `secs` and `nanos` and **no scalar an operator can type is a value + /// for it**. A bare `60` fails to deserialize exactly as `"60"` does, which is + /// why this is its own kind rather than a [`Kind::Number`]: the number the + /// operator means is right and the spelling is not theirs to know. + Duration, /// 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 @@ -291,7 +390,7 @@ pub enum Kind { /// /// **Both halves are the dependency's since io-harness 0.71.0, and neither is /// written here any more**: the list is `Effect::ALL` -/// (`io-harness-0.82.0/src/policy.rs:129`) and each spelling is `Effect::as_str` +/// (`io-harness-0.83.0/src/policy.rs:129`) and each spelling is `Effect::as_str` /// (`:145`), which is the word io-harness's own deserializer reads. /// /// Until this release io-cli held a copy of both — an array naming three variants @@ -318,7 +417,7 @@ pub fn effects() -> Vec { /// The `ExecMode` variants, spelled by io-harness itself. /// -/// **The list is `ExecMode::ALL` (`io-harness-0.82.0/src/sandbox.rs:453`) and the +/// **The list is `ExecMode::ALL` (`io-harness-0.83.0/src/sandbox.rs:453`) and the /// spellings are `ExecMode::as_str` (`:460`).** io-cli wrote the variant list out /// by hand until this release for a reason that was the dependency's and not a /// choice made here: `ExecMode` is `#[non_exhaustive]` (`sandbox.rs:407`), and @@ -477,6 +576,18 @@ pub fn source_for(key: &str, words: &[String]) -> Result { // the value it meant instead of a refused write. Ok(number.to_string()) } + // Seconds in, an inline table out. The operator types the number they + // mean; the spelling belongs to serde's `Duration`, and asking a person to + // know it is asking them to know a dependency's derive. + Some(Kind::Duration) => { + let seconds: u64 = word.parse().map_err(|_| { + format!( + "`{word}` is not a whole number of seconds, and `{key}` is a wall-clock \ + ceiling counted in them" + ) + })?; + Ok(format!("{{ secs = {seconds}, nanos = 0 }}")) + } // A model name, a path and free text are strings in the file, and all // three are escaped rather than wrapped in quotes by hand. Some(Kind::Model | Kind::File | Kind::Text) => Ok(crate::servers::quoted(word)), @@ -604,6 +715,7 @@ fn offered(key: &str, kind: Option<&Kind>) -> String { .join(", ") ), Some(Kind::Number { .. }) => "it takes a whole number".to_string(), + Some(Kind::Duration) => "it takes a whole number of seconds".to_string(), // Named without a command word for the same reason as the two refusals // above: both doors reach this sentence. _ => format!("reading `{key}` shows what it is set to now"), @@ -642,7 +754,9 @@ pub fn kind_of(key: &str) -> Option { | "app.io-cli.plain" | "app.io-cli.detached_spawns" | "app.io-cli.gates.allow_self_review" - | "app.io-cli.conversational" => Kind::Flag, + | "app.io-cli.conversational" + | "app.io-cli.reference_catalogue" + | "app.io-cli.browser.headless" => 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" @@ -672,12 +786,23 @@ pub fn kind_of(key: &str) -> Option { | "app.io-cli.containment.max_concurrent_agents" | "app.io-cli.containment.max_depth" | "app.io-cli.containment.max_total_tokens" - | "app.io-cli.containment.max_total_cost" => Kind::Number { signed: false }, + | "app.io-cli.containment.max_total_cost" + // io-cli's own count of what the last catalogue read held, and the three + // `BrowserConfig` numbers. All four were quoted by the fall-through until + // this release, and each one of them alone failed the whole section. + | "app.io-cli.prices.models" + | "app.io-cli.browser.width" + | "app.io-cli.browser.height" + | "app.io-cli.browser.timeout_secs" => 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, + // Two lists now, and the second needs no arm of its own anywhere: a + // command line and a browser's extra arguments are both "the remaining + // words are the value", which is what [`Kind::List`] already means. + "app.io-cli.gates.command" | "app.io-cli.browser.args" => Kind::List, + "app.io-cli.containment.max_total_duration" => Kind::Duration, "app.io-cli.gates.contains" | "app.io-cli.gates.rubric" | "app.io-cli.prices.source_url" => Kind::Text, @@ -692,7 +817,7 @@ pub fn kind_of(key: &str) -> Option { /// preference — but half of the old reason is now false and the correction is /// worth writing down.** io-harness 0.71.0 names its own defaults: /// `DEFAULT_MAX_STEPS` = 8, `DEFAULT_WORKSPACE_MAX_STEPS` = 12 and -/// `DEFAULT_MAX_RETRIES` = 2 (`io-harness-0.82.0/src/contract.rs:755,773,789`), +/// `DEFAULT_MAX_RETRIES` = 2 (`io-harness-0.83.0/src/contract.rs:780,798,814`), /// re-exported at the crate root. "There is nothing to read" was true when this /// was written and is not true now. What is still true is that none of it anchors /// *this* ladder: @@ -790,6 +915,10 @@ pub fn spell_value(kind: &Kind, value: &str) -> String { let bare = value.trim().trim_matches('"'); match kind { Kind::Flag | Kind::Number { .. } => bare.to_string(), + // Already an inline table by the time it is here — `source_for` is what + // turns a number of seconds into one, and re-wrapping it would produce a + // table inside a string. + Kind::Duration => bare.to_string(), Kind::List => { let words: Vec<&str> = bare.split_whitespace().collect(); crate::edit::array(&words) @@ -824,6 +953,14 @@ pub fn shape_of(key: &str, config: &Config) -> Option { "app.io-cli.prices.source_url" => { "a URL returning a model catalogue — for example: https://openrouter.ai/api/v1/models" } + "app.io-cli.browser.args" => { + "extra arguments for the browser, split on spaces into a list — for example: \ + --disable-gpu --no-sandbox" + } + "app.io-cli.containment.max_total_duration" => { + "a whole number of seconds for the whole tree, written into the file as the table \ + io-harness reads a duration from — for example: 3600" + } "prices.as_of" => { "written by the price refresh rather than typed; choose it on `/config` and the \ refresh that re-reads the catalogue is the row after `leave it`" @@ -843,7 +980,7 @@ pub fn shape_of(key: &str, config: &Config) -> Option { /// The models `[prices.models]` names, across every scope, sorted and deduplicated. /// /// **Read from the dependency's own table since io-harness 0.71.0, not scraped -/// out of the files.** `PriceTable::models` (`io-harness-0.82.0/src/pricing.rs:268`) +/// out of the files.** `PriceTable::models` (`io-harness-0.83.0/src/pricing.rs:268`) /// lists every model the table can actually price, and [`Config::prices`] has /// always built that table out of the three scopes — so the merged question this /// used to hand-roll is precisely the one the accessor answers, and the gap filed @@ -869,7 +1006,7 @@ pub fn shape_of(key: &str, config: &Config) -> Option { /// /// **This takes the `Config` the caller already holds, and must never re-discover /// one.** `Config::discover` resolves every `${env:}`, `${file:}` and `${cmd:}` as -/// it reads (`io-harness-0.82.0/src/config.rs:627`), so a second discovery re-runs +/// it reads (`io-harness-0.83.0/src/config.rs:627`), so a second discovery re-runs /// an operator's credential commands — which for a `${cmd:}` fetching a key out of /// a keychain means a Touch-ID prompt raised in order to draw a menu, every time /// the picker opens. Taking a `&Config` is not an optimisation; it is the @@ -950,7 +1087,7 @@ pub fn destination(config: &Config, key: &str) -> (Scope, bool) { #[must_use] pub fn widens_workspace(key: &str, value: &str) -> bool { /// The clause io-harness's widening refusal always carries - /// (`io-harness-0.82.0/src/config.rs:2949`). Matched rather than the whole + /// (`io-harness-0.83.0/src/config.rs:2949`). Matched rather than the whole /// sentence, which interpolates the path, the key and the destination scope. const WIDENS: &str = "widens the boundary"; @@ -1085,7 +1222,7 @@ fn is_credential(path: &str) -> bool { /// /// **There are three substitution forms and not two.** io-harness resolves /// `${env:...}`, `${file:...}` **and** `${cmd:...}` -/// (`substitute`, `io-harness-0.82.0/src/config.rs:3159`, the `cmd` arm at +/// (`substitute`, `io-harness-0.83.0/src/config.rs:3159`, the `cmd` arm at /// `:3279`); this comment claimed two until /// 0.21.0, and the sentence it claimed it in was the argument for which forms /// pass through here. The third is deliberately not one of them: a `${env:}` or @@ -1197,7 +1334,24 @@ pub fn said(setting: &Setting) -> String { /// One key, resolved. pub fn setting(config: &Config, key: &str) -> Setting { - let origins = config.origin(key); + // **A duration is a table in the file, so io-harness's origins name its fields + // and never the key itself** (0.40.0). `Config::origin` walks the merged + // document to a leaf, and `max_total_duration` is not one — its leaves are + // `secs` and `nanos`. Asking for the key alone answered nothing, so a value + // just written read back as unset, and `io config get` said `default` for a + // ceiling the operator had set. `secs` is asked for rather than either leaf + // because `nanos` may legitimately be absent from a hand-written table. + let origins = match kind_of(key) { + Some(Kind::Duration) => { + let inner = config.origin(&format!("{key}.secs")); + if inner.is_empty() { + config.origin(key) + } else { + inner + } + } + _ => config.origin(key), + }; let decided = match origins.last() { // The last origin is the winning one: the scopes merge in precedence // order, and a key more than one file named lists them in that order. diff --git a/src/main.rs b/src/main.rs index a480ce0..f9b8079 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10696,6 +10696,7 @@ fn value_rows( // Typed, and the caller says what shape. io_cli::configure::Kind::List | io_cli::configure::Kind::Text + | io_cli::configure::Kind::Duration | io_cli::configure::Kind::Machine => return None, }; let (scope, inherited) = io_cli::configure::destination(config, key); diff --git a/tests/configure.rs b/tests/configure.rs index a71926c..07a68cd 100644 --- a/tests/configure.rs +++ b/tests/configure.rs @@ -533,6 +533,175 @@ fn f8_every_documented_harness_key_deserialises() { } } +/// **F1 — every documented `[app.io-cli]` key round-trips through the shell door.** +/// +/// The thirteen keys 0.40.0 made settable, each written the way `io config set` +/// writes it, read back naming its scope, and — the part that is the whole point — +/// leaving `settings::stored` with no warning. That warning is the failure this +/// release exists to stop: io-harness reads `[app.io-cli]` as one opaque value, so +/// a single mis-typed key does not fail that key, it fails the section, and the +/// session reverts theme, keybindings, containment, gates and routing to defaults +/// behind one line. +/// +/// Written as a table of thirteen rather than thirteen tests because the property +/// is the same for each and the list is the thing that must not go short — and it +/// is checked against `CATALOGUE` at the end, so a key added to the catalogue and +/// not to this table fails here rather than shipping unexercised. +/// +/// Sabotage: give any one of them back to the `[app.io-cli]` fall-through by +/// removing its `kind_of` arm. `source_for` quotes it, the value is a string where +/// a number or a flag belongs, and this goes red on the warning rather than on the +/// value — which is the failure an operator actually met. +#[test] +fn f1_every_settable_app_key_round_trips_through_the_shell_door() { + // The containment table's first four fields are required by io-harness — there + // is no safe default for somebody else's token ceiling — so the fixture starts + // with a complete one. A file holding `max_depth` alone is a file that does not + // deserialize, and testing against one would confuse this release's defect with + // that one. + let s = scopes( + "[app.io-cli]\ntheme = \"dark\"\n\n[app.io-cli.containment]\n\ + max_total_agents = 12\nmax_concurrent_agents = 4\nmax_depth = 2\n\ + max_total_tokens = 200000\n", + "", + "", + ); + let written: &[(&str, &[&str])] = &[ + ("app.io-cli.reference_catalogue", &["false"]), + ("app.io-cli.prices.models", &["417"]), + ("app.io-cli.browser.headless", &["true"]), + ("app.io-cli.browser.width", &["1280"]), + ("app.io-cli.browser.height", &["800"]), + ("app.io-cli.browser.timeout_secs", &["30"]), + ( + "app.io-cli.browser.args", + &["--disable-gpu", "--no-sandbox"], + ), + ("app.io-cli.containment.max_total_duration", &["3600"]), + ("app.io-cli.containment.max_total_agents", &["24"]), + ("app.io-cli.containment.max_concurrent_agents", &["6"]), + ("app.io-cli.containment.max_depth", &["3"]), + ("app.io-cli.containment.max_total_tokens", &["400000"]), + ("app.io-cli.containment.max_total_cost", &["5"]), + ]; + + for (key, words) in written { + let words: Vec = words.iter().map(|word| (*word).to_string()).collect(); + let source = configure::source_for(key, &words).unwrap_or_else(|e| { + panic!("`{key}` is documented and `io config set` refuses it: {e}") + }); + // The environment guard is held for the write alone and released before + // the read: `Scopes::config` takes the same lock, and it is not reentrant. + { + let _guard = env_lock(); + std::env::set_var("IO_CONFIG", &s.user); + let written = configure::write( + s.root.path(), + Scope::User, + &[io_cli::edit::Edit::set(*key, source.as_str())], + ); + std::env::remove_var("IO_CONFIG"); + written.unwrap_or_else(|e| panic!("the write of `{key}` was refused: {e}")); + } + + let config = s.config(); + + // The session's own read. `None` is no warning, and no warning is the + // whole criterion: the section deserialized. + let (stored, warning) = io_cli::settings::stored(&config); + assert!( + warning.is_none(), + "after `io config set {key} {}`, a session reports: {warning:?}", + words.join(" "), + ); + assert!( + stored.is_some(), + "after `io config set {key}`, [app.io-cli] read back as nothing at all", + ); + + // `io config get`'s answer: the value, and the scope that decided it. + let setting = configure::setting(&config, key); + assert!( + setting.value.is_some(), + "`io config get {key}` answers nothing for a key just written", + ); + assert!( + matches!( + setting.decided, + configure::Decided::File { + scope: Scope::User, + .. + } + ), + "`{key}` was written to the user scope and `io config get` says it was decided \ + by {:?}", + setting.decided, + ); + } + + // The table is the catalogue's own list, so a key made settable and not + // exercised here fails rather than shipping untested. `theme` and the keys + // that were already settable before 0.40.0 are covered by the tests above; + // what this asserts is that nothing in the catalogue is settable in principle + // and unwritten in practice. + for key in configure::CATALOGUE + .iter() + .filter(|key| key.starts_with("app.io-cli.containment.") || key.contains(".browser.")) + { + assert!( + written.iter().any(|(named, _)| named == key), + "`{key}` is offered by the catalogue and this gate never writes it", + ); + } +} + +/// **F1's negative half — a bad value is still refused, and one absent key does +/// not fail the section.** +/// +/// The controls that matter. Typing a key is worth nothing if the type is not +/// enforced, and the release would be a regression if giving thirteen keys a kind +/// made a file holding twelve of them stop parsing. +#[test] +fn f1_a_typed_key_still_refuses_a_value_and_an_absent_key_still_loads() { + for (key, word) in [ + ("app.io-cli.reference_catalogue", "yes"), + ("app.io-cli.browser.headless", "on"), + ("app.io-cli.browser.width", "wide"), + ("app.io-cli.containment.max_depth", "deep"), + ("app.io-cli.containment.max_total_duration", "an hour"), + ] { + let refusal = configure::source_for(key, &[word.to_string()]); + assert!( + refusal.is_err(), + "`{word}` is not a value `{key}` takes and it was accepted", + ); + } + + // A negative number where something is counted, which is the arm a `Kind` + // without a sign would have let through. + assert!(configure::source_for("app.io-cli.browser.width", &["-1".to_string()]).is_err()); + + // And the section still loads with one of the thirteen absent — every one of + // them is optional except the containment table's own required four. + let s = scopes( + "[app.io-cli]\ntheme = \"dark\"\nreference_catalogue = false\n\n\ + [app.io-cli.browser]\nheadless = true\n", + "", + "", + ); + let (stored, warning) = io_cli::settings::stored(&s.config()); + assert!( + warning.is_none(), + "a file naming two keys warns: {warning:?}" + ); + let stored = stored.expect("[app.io-cli] read back"); + assert_eq!(stored.reference_catalogue, Some(false)); + assert!( + stored.containment.is_none(), + "nothing invented a containment" + ); +} + // --- F3: the write lands in the scope that was picked, and takes effect ------- #[test] @@ -937,19 +1106,82 @@ fn f1_only_three_keys_are_authored_text() { ); } -/// `app.io-cli.gates.command` is a list and not text. +/// The two list keys are lists and not text. /// -/// Sabotage: classify it as authored text. Under it this test fails, and the +/// Sabotage: classify either 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. +/// +/// **`app.io-cli.browser.args` is the second, and it needed no new arm anywhere** +/// (0.40.0). It is the same act `app.io-cli.gates.command` has been since 0.24.0 — +/// the remaining words are the value — so the release that made it settable added +/// a catalogue row, a `kind_of` arm and a shape sentence, and touched no writer. #[test] -fn f1_the_command_key_is_the_one_list() { - let lists: Vec<&str> = configure::CATALOGUE +fn f1_the_list_keys_are_the_two_lists() { + let mut 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"]); + lists.sort_unstable(); + assert_eq!( + lists, + vec!["app.io-cli.browser.args", "app.io-cli.gates.command"] + ); +} + +/// `app.io-cli.containment.max_total_duration` is a duration, and no scalar is a +/// value for it. +/// +/// **This is the control the exclusion argument rests on, and it is a real round +/// trip rather than a reading of serde's derive** (0.40.0). `Containment` holds +/// `Option` with no `#[serde(with)]` on it, so what deserializes is a +/// struct of `secs` and `nanos` — which means the bare `60` an operator would +/// type fails for exactly the reason the quoted `"60"` this release removes fails. +/// The key is settable because `Kind::Duration` writes the table for them, and +/// that is the one shape in the catalogue an operator is not asked to know. +/// +/// Sabotage: give the key `Kind::Number { signed: false }`. `source_for` then +/// writes a bare `60`, and the deserialization below refuses it. +#[test] +fn f1_the_wall_clock_ceiling_is_written_as_the_table_serde_reads() { + assert_eq!( + configure::kind_of("app.io-cli.containment.max_total_duration"), + Some(configure::Kind::Duration) + ); + let source = configure::source_for( + "app.io-cli.containment.max_total_duration", + &["3600".to_string()], + ) + .expect("a whole number of seconds is a value for it"); + + // Through io-cli's own settings type, because that is what fails at a session + // start when the section cannot be read — the failure this release exists to + // stop happening in silence. + let file = format!( + "max_total_agents = 12\nmax_concurrent_agents = 4\nmax_depth = 2\n\ + max_total_tokens = 200000\nmax_total_duration = {source}\n" + ); + let read: io_harness::Containment = + toml::from_str(&file).expect("the value io writes is a value io-harness reads back"); + assert_eq!( + read.max_total_duration, + Some(std::time::Duration::from_secs(3_600)) + ); + + // And the two spellings a person would reach for are both refused, which is + // why the key has a kind of its own rather than being a number. + for scalar in ["3600", "\"3600\""] { + let file = format!( + "max_total_agents = 12\nmax_concurrent_agents = 4\nmax_depth = 2\n\ + max_total_tokens = 200000\nmax_total_duration = {scalar}\n" + ); + assert!( + toml::from_str::(&file).is_err(), + "`{scalar}` is not a value io-harness reads as a duration, so writing one \ + would fail the whole [app.io-cli] section behind a single warning line", + ); + } } /// `prices.as_of` is written by machinery and is never offered for typing. diff --git a/tests/docs.rs b/tests/docs.rs index ace6540..91abf66 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -267,18 +267,98 @@ fn the_readme_documents_every_key_of_the_io_cli_section() { let page = guide("configuration"); let table = settings_table(&page); - // Every field set, because `skip_serializing_if` drops a `None` — a default - // value here would assert against a table with nothing in it. - let every = io_cli::settings::CliSettings { + let value = serde_json::to_value(every_setting()).expect("[app.io-cli] serializes"); + let keys = value.as_object().expect("a table"); + + let (mut scalars, mut tables) = (0, 0); + for (name, held) in keys { + let spellings = [ + format!("| `{name}` |"), + format!("| `[app.io-cli.{name}]` |"), + format!("| `[[app.io-cli.{name}]]` |"), + ]; + assert!( + spellings.iter().any(|row| table.contains(row)), + "the guide's [app.io-cli] table has no row for `{name}`", + ); + if held.is_object() || held.is_array() { + tables += 1; + } else { + scalars += 1; + } + } + + let rows = table.lines().filter(|line| line.starts_with("| `")).count(); + assert_eq!( + rows, + keys.len(), + "the guide's [app.io-cli] table has {rows} rows and the section has {}", + keys.len(), + ); + + // The sentence above the table counts them, and a count written in prose is + // the first half of a table to go stale — this one said "five keys and four + // tables" over a table of five keys and five tables, for four releases. + let words = [ + "no", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", + "Eleven", "Twelve", + ]; + let sentence = format!( + "{} keys live there, and {} tables", + words[scalars], + words[tables].to_lowercase(), + ); + assert!( + page.contains(&sentence), + "the guide should say `{sentence}`", + ); +} + +/// `[app.io-cli]` with every field of every sub-table set. +/// +/// **Every field written out, and no `..Default::default()` anywhere in it.** A +/// `skip_serializing_if` drops a `None`, so a default value here would assert +/// against a table with nothing in it — and, more to the point, the struct +/// literals are the forced decision: adding a field to `CliSettings`, to +/// `gates::Settings`, to `routing::Settings`, or to either of the two io-harness +/// types this section embeds stops this file compiling until somebody says what +/// the new key is. That property is why the fixture is shared rather than copied: +/// the row check below and `f2_every_settings_key_is_named_by_one_of_the_two_lists` +/// must be looking at the same struct, or the second one is checking a census the +/// first has already gone stale on. +fn every_setting() -> io_cli::settings::CliSettings { + io_cli::settings::CliSettings { theme: Some("dark".into()), diff: Some("unified".into()), glyphs: Some("ascii".into()), plain: Some(false), keys: Some(Default::default()), - containment: Some(io_harness::Containment::new(12, 4, 2, 200_000)), + // A struct literal from 0.40.0, not `Containment::new`, which leaves + // `max_total_cost` and `max_total_duration` at `None` — and a `None` is + // dropped by `skip_serializing_if`, so the two keys this release makes + // settable would have been invisible to the walk that exists to find them. + containment: Some(io_harness::Containment { + max_total_agents: 12, + max_concurrent_agents: 4, + max_depth: 2, + max_total_tokens: 200_000, + max_total_cost: Some(5), + max_total_duration: Some(std::time::Duration::from_secs(3_600)), + }), mcp: Some(Vec::new()), lsp: Some(Vec::new()), - browser: Some(io_harness::BrowserConfig::default()), + // A struct literal for the same reason, and it is the one that paid: the + // `BrowserConfig::default()` this used to call left `binary` at `None`, + // and `binary` is the fourteenth key the release contract's own risk + // predicted and this fixture found. + browser: Some(io_harness::BrowserConfig { + binary: Some("/usr/bin/chromium".into()), + args: vec!["--disable-gpu".into()], + headless: true, + width: 1_280, + height: 800, + timeout_secs: 30, + }), skills: Some("/skills".into()), max_parallel_reads: Some(16), spawn_background_after_secs: Some(120), @@ -320,52 +400,95 @@ fn the_readme_documents_every_key_of_the_io_cli_section() { }), }), reference_catalogue: Some(false), - }; - let value = serde_json::to_value(&every).expect("[app.io-cli] serializes"); - let keys = value.as_object().expect("a table"); + } +} - let (mut scalars, mut tables) = (0, 0); - for (name, held) in keys { - let spellings = [ - format!("| `{name}` |"), - format!("| `[app.io-cli.{name}]` |"), - format!("| `[[app.io-cli.{name}]]` |"), - ]; - assert!( - spellings.iter().any(|row| table.contains(row)), - "the guide's [app.io-cli] table has no row for `{name}`", - ); - if held.is_object() || held.is_array() { - tables += 1; - } else { - scalars += 1; +/// Every path in the settings struct that neither list names, given a table and +/// the key it sits under. +/// +/// **A path stops descending the moment one of the two lists names it**, which is +/// what makes the walk agree with the surface rather than with serde. Both +/// `[app.io-cli.keys]` and `containment.max_total_duration` are objects to serde — +/// a map of chords and a `Duration`'s `secs`/`nanos` — and descending into either +/// would demand catalogue rows for values that are not settings at all. +fn unnamed_leaves(value: &serde_json::Value, at: &str, out: &mut Vec) { + if io_cli::configure::CATALOGUE.contains(&at) + || io_cli::configure::EXCLUDED + .iter() + .any(|(key, _)| *key == at) + { + return; + } + match value { + serde_json::Value::Object(fields) => { + for (name, held) in fields { + unnamed_leaves(held, &format!("{at}.{name}"), out); + } } + _ => out.push(at.to_string()), } +} - let rows = table.lines().filter(|line| line.starts_with("| `")).count(); - assert_eq!( - rows, - keys.len(), - "the guide's [app.io-cli] table has {rows} rows and the section has {}", - keys.len(), - ); +/// **F2 — a key the settings struct holds cannot be missing from the catalogue.** +/// +/// The existing gates walk `CATALOGUE` and check the documentation, and the +/// documentation and check `CATALOGUE`. Both were green while eight keys of +/// `CliSettings` were named by neither — `reference_catalogue` among them, which +/// 0.39.0 shipped as the way to turn a network call off and which `io config set` +/// then wrote as a string that took the whole `[app.io-cli]` section down. **A +/// list cannot notice what is missing from it.** So this is the other direction: +/// from the struct, into its sub-tables, to a leaf that must be named by +/// `CATALOGUE` or by `EXCLUDED` and nothing else. +/// +/// Sabotage: delete one entry from `CATALOGUE`. This goes red naming that key, +/// and `the_readme_documents_every_key_of_the_io_cli_section` and +/// `every_catalogue_key_is_documented` both stay green — which is the proof the +/// two directions are not the same test. +#[test] +fn f2_every_settings_key_is_named_by_one_of_the_two_lists() { + let value = serde_json::to_value(every_setting()).expect("[app.io-cli] serializes"); + let mut unnamed = Vec::new(); + unnamed_leaves(&value, "app.io-cli", &mut unnamed); - // The sentence above the table counts them, and a count written in prose is - // the first half of a table to go stale — this one said "five keys and four - // tables" over a table of five keys and five tables, for four releases. - let words = [ - "no", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", - "Eleven", "Twelve", - ]; - let sentence = format!( - "{} keys live there, and {} tables", - words[scalars], - words[tables].to_lowercase(), - ); assert!( - page.contains(&sentence), - "the guide should say `{sentence}`", + unnamed.is_empty(), + "these are fields of CliSettings that `configure::CATALOGUE` does not offer \ + and `configure::EXCLUDED` does not refuse, so they are settable only by \ + hand-editing the file and `io config set` writes each of them as a string \ + that fails the whole [app.io-cli] section: {unnamed:?}", ); + + // The control, and it is the one that matters: a walk that descended into + // nothing would satisfy the assertion above vacuously. Every catalogue key + // under `[app.io-cli]` has to be a leaf this walk can actually reach. + let mut reached = Vec::new(); + reach_all(&value, "app.io-cli", &mut reached); + for key in io_cli::configure::CATALOGUE + .iter() + .filter(|key| key.starts_with("app.io-cli.")) + { + assert!( + reached.iter().any(|found| found == key), + "`{key}` is offered by the catalogue and is not a field of CliSettings that \ + serializes — so either the key is spelled wrong or the walk above is not \ + descending into the table that holds it, and this gate is checking nothing", + ); + } +} + +/// Every path the settings struct serializes, with no list consulted. +/// +/// The census `f2_every_settings_key_is_named_by_one_of_the_two_lists` checks +/// itself against. `unnamed_leaves` stops at a named path by design, so it cannot +/// answer "did this walk reach that key at all" — only a walk that consults +/// nothing can. +fn reach_all(value: &serde_json::Value, at: &str, out: &mut Vec) { + out.push(at.to_string()); + if let serde_json::Value::Object(fields) = value { + for (name, held) in fields { + reach_all(held, &format!("{at}.{name}"), out); + } + } } #[test] @@ -2625,9 +2748,9 @@ fn n5_no_shipped_page_claims_a_span_was_delivered() { /// the risk is a sentence rather than a branch.** io-harness offers a masked turn a /// byte-identical catalogue on purpose: the tool array sits ahead of the provider's /// cache breakpoint, so dropping a definition would save its tokens once and pay a -/// cache *write* on every later turn (`io-harness-0.82.0/src/tools/mod.rs:33-42`). +/// cache *write* on every later turn (`io-harness-0.83.0/src/tools/mod.rs:33-42`). /// Withholding in fact makes the request marginally **larger**, by one sentence -/// naming what is withheld (`io-harness-0.82.0/src/run/prompts.rs:1381`). +/// naming what is withheld (`io-harness-0.83.0/src/run/prompts.rs:1418`). /// /// The roadmap entry 0.37.0 was planned from assumed the opposite and said so in /// its headline. That framing is what a writer reaches for, because "withhold" means From 154542fe5b7805773e8996a37d2753bfd221078d Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Mon, 7 Sep 2026 08:15:08 +0530 Subject: [PATCH 3/9] feat(approval): an approval waits for Enter, and a letter only chooses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Through 0.39.0 `y`, `a` and `n` each resolved an approval on their own, so an operator who began typing their next instruction while one was up had already answered it with whatever letter came first. The 2026-09-05 field test caught it: the `n` in "end" denied a command, and the composer was left holding `lways enE.`, which was then sent as a prompt. Both ways in are still two ways in — a letter for the reader who knows the key, arrows for the one who does not — and both now end at the same `Enter`. It costs one keystroke; it buys that no single character an operator types can decide a write. Buffering and replaying the typed sentence is the fuller answer and is deliberately not here: it makes this path conditional on buffer state, and stopping the unintended decision is the part that could not wait. A boundary product whose whole claim is that a person decides cannot have a door where a letter inside a sentence decides for them. --- docs/guide/keys.md | 10 +++++- src/approval.rs | 44 ++++++++++++++++++------ src/commands.rs | 2 +- tests/approval.rs | 84 ++++++++++++++++++++++++++++++++++++++++++++-- tests/contract.rs | 23 +++++++++---- 5 files changed, 142 insertions(+), 21 deletions(-) diff --git a/docs/guide/keys.md b/docs/guide/keys.md index 8f3c805..f159f5e 100644 --- a/docs/guide/keys.md +++ b/docs/guide/keys.md @@ -15,7 +15,7 @@ | `Esc` | at an empty prompt, ask whether to undo the last turn — its files and all | | `Ctrl+T` | put the whole conversation back into the scrollback | | `Ctrl+F` | show the fleet: the children this turn has spawned | -| `y / a / n` | answer an approval: allow once, allow this session, deny | +| `y / a / n` | choose an approval's answer: allow once, allow this session, deny — `Enter` gives it | | `Esc` | stop the running turn, or close a picker without choosing | | `Tab` | in any list, take the row under the marker; `Shift+Tab` steps back | | `/` | open the command palette — at the prompt or while a turn runs | @@ -137,6 +137,14 @@ owns the keyboard while it is up — the composer, an approval, a picker — and approval's `y`, `a` and `n` are the *words* of the answer rather than shortcuts for it. +**From 0.40.0 an approval waits for `Enter`, and this is a change to a key you +already know.** `y`, `a` and `n` move the highlight to that answer; nothing +resolves until you press `Enter`, which is the same two-step the arrow keys have +always used. It costs one keystroke and it buys the thing that was wrong: a +letter inside a sentence can no longer decide a write. Typing "no, not that one" +at an approval used to deny with the `n` and leave the rest of the sentence in the +composer, which was then sent as a prompt. + **`Ctrl+C` is fixed, and it is the only one that is.** It interrupts a running turn and leaves `io`, so a configuration file able to take it away is one able to lock you inside a running agent. Both spellings of that mistake are refused out diff --git a/src/approval.rs b/src/approval.rs index f6785a7..6432237 100644 --- a/src/approval.rs +++ b/src/approval.rs @@ -227,7 +227,7 @@ const PAGE: usize = 8; /// /// A constant because two things need it to be the same string: the row that /// draws it, and the test that proves an ignored key is not ignored silently. -pub const ONLY_THREE_KEYS: &str = "press y, a or n"; +pub const ONLY_THREE_KEYS: &str = "y, a or n chooses — enter answers"; /// An open question, and the answer being chosen. /// @@ -313,9 +313,11 @@ impl Approval { /// A keystroke. `Some` means the operator answered and this overlay is over. /// - /// Two ways in, on purpose: a letter for the reader who knows the key, and - /// arrows with `Enter` for the one who does not. A key that only works when - /// you already know it is not an interface. + /// **Only `Enter` ever returns `Some` (0.40.0).** Two ways in are still two + /// ways in — a letter for the reader who knows the key, arrows for the one who + /// does not — but both move the highlight and neither decides. A key that only + /// works when you already know it is not an interface; a key that decides a + /// write because it happened to be the first letter of a sentence is worse. pub fn key(&mut self, key: KeyEvent) -> Option { // Cleared up front and set again only by the arm that refuses, so the // mark always describes the key that has just arrived. Doing it in the @@ -371,14 +373,36 @@ impl Approval { None } KeyCode::Enter => Some(self.chosen()), + // **A letter moves the highlight and answers nothing (0.40.0).** Until + // this release `y`, `a` and `n` each resolved the approval on their + // own, which meant an operator who began typing their next instruction + // while an approval was up had already answered it with whatever letter + // came first. The 2026-09-05 field test caught it as the `n` in "end" + // denying a command, leaving the composer holding `lways enE.`, which + // was then sent as a prompt. + // + // The two ways in are still two ways in — a letter for the reader who + // knows the key, arrows for the one who does not — and both now end at + // the same `Enter`. What is given up is one keystroke; what is bought + // is that no single character an operator types can decide a write. + // + // Buffering the sentence and replaying it into the composer is the + // fuller answer and is deliberately not here: it makes this path + // conditional on buffer state, and stopping the unintended decision is + // the part that could not wait. KeyCode::Char(c) => { - let answer = Answer::ALL + let found = Answer::ALL .into_iter() - .find(|answer| answer.key() == c.to_ascii_lowercase()); - // A letter that is not one of the three is the operator typing - // prose into a modal. It answers nothing, and now it says so. - self.nudged = answer.is_none(); - answer + .position(|answer| answer.key() == c.to_ascii_lowercase()); + match found { + Some(index) => { + self.chosen = index; + } + // A letter that is not one of the three is the operator typing + // prose into a modal. It moves nothing, and it says so. + None => self.nudged = true, + } + None } _ => { self.nudged = true; diff --git a/src/commands.rs b/src/commands.rs index 528ad38..2a22bdf 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -96,7 +96,7 @@ pub const KEYS: &[(&str, &str)] = &[ ), ( "y / a / n", - "answer an approval: allow once, allow this session, deny", + "choose an approval's answer: allow once, allow this session, deny — `Enter` gives it", ), ( "Esc", diff --git a/tests/approval.rs b/tests/approval.rs index 125acf7..e596a4d 100644 --- a/tests/approval.rs +++ b/tests/approval.rs @@ -296,9 +296,14 @@ async fn the_overlay_says_what_it_did_not_show() { deciding.await.expect("the approver did not panic"); } -/// **F3.** Each key is its own answer, the run is told exactly that, and the -/// transcript gains exactly one line saying so — so a decision is in the terminal's -/// own scrollback as well as in the harness's durable trace. +/// **F3.** Each key chooses its own answer, `Enter` gives it, the run is told +/// exactly that, and the transcript gains exactly one line saying so — so a +/// decision is in the terminal's own scrollback as well as in the harness's +/// durable trace. +/// +/// **The `Enter` is 0.40.0's and it is the behaviour change.** Through 0.39.0 the +/// letter alone answered, which is what let the `n` in a typed sentence deny a +/// write. #[tokio::test] async fn f3_each_answer_reaches_the_run_as_itself_and_commits_one_line() { for (key, expected) in [ @@ -314,6 +319,11 @@ async fn f3_each_answer_reaches_the_run_as_itself_and_commits_one_line() { assert!(app.asking(), "the overlay is up before the key"); app.key(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE)); + assert!( + app.asking(), + "a letter chooses and does not answer, so the overlay is still up: {key:?}", + ); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(!app.asking(), "answering closes the overlay: {key:?}"); let decision = deciding.await.expect("the approver did not panic"); @@ -401,9 +411,76 @@ async fn an_open_question_takes_the_keyboard() { ); app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); deciding.await.expect("the approver did not panic"); } +/// **F3 — an approval is not answered by a keystroke.** +/// +/// The failure this release exists to remove, asserted as the thing that happened +/// rather than as a rule. In the 2026-09-05 field test an operator typed an +/// instruction while an approval was up; the `n` in "end" denied the write, and +/// the composer was left holding the rest of the sentence, which was then sent as +/// a prompt. +/// +/// A sentence containing both `n` and `y` leaves the highlight where the last of +/// them put it and decides nothing — which is the part that could not be asserted +/// before, because the first letter used to end the overlay. +/// +/// Sabotage: return the found answer from `Approval::key`'s `Char` arm instead of +/// storing it. The first row goes red on the pending act having been decided. +#[tokio::test] +async fn f3_a_typed_sentence_moves_the_highlight_and_decides_nothing() { + let (request, context) = flagged(); + let (ask, deciding) = asked(request, context).await; + + let mut approval = Approval::new(ask, std::path::Path::new("")); + assert_eq!( + approval.chosen(), + approval::Answer::Once, + "the overlay opens on the least committal answer", + ); + + // "never mind" — an `n`, an `e`, a `v`, and so on. Not one of them answers. + for c in "never mind".chars() { + assert!( + approval + .key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) + .is_none(), + "`{c}` answered an approval on its own", + ); + } + // `n` was the first letter and `d` the last; the highlight is on the last of + // the three answer letters the sentence happened to contain, which is `n`. + assert_eq!( + approval.chosen(), + approval::Answer::Deny, + "a letter that names an answer moves the highlight to it", + ); + + // And a sentence whose letters name all three ends on the last of them: `a` + // then `n` then `y`, so the highlight finishes on `y`. + for c in "any".chars() { + assert!(approval + .key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) + .is_none()); + } + assert_eq!( + approval.chosen(), + approval::Answer::Once, + "the last answer letter typed is the one highlighted", + ); + + // Nothing has been decided by any of it. The act is still pending, which is + // the property the whole change exists for. + approval.answer(approval::Answer::Once); + let decision = deciding.await.expect("the approver did not panic"); + assert!( + matches!(decision, Decision::Approve { .. }), + "the answer is the one that was given deliberately, not one a letter made: {decision:?}", + ); +} + /// **F5.** The harness's own `remember` is run-scoped: it applies for the rest of /// the turn and dies with it. So the answer is asserted where it has to survive — /// on the policy the *next* turn is handed — and asserted as a verdict rather than @@ -426,6 +503,7 @@ async fn f5_allowing_for_the_session_survives_into_the_next_turn() { let mut app = App::new(DARK, "opus-5"); app.open_approval(ask); app.key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); deciding.await.expect("the approver did not panic"); let next = approval::effective_policy(&base, app.remembered()); diff --git a/tests/contract.rs b/tests/contract.rs index 164eb72..beeed87 100644 --- a/tests/contract.rs +++ b/tests/contract.rs @@ -2109,15 +2109,26 @@ fn f11_asking_for_the_default_explicitly_changes_nothing() { /// /// Sabotage: add a key to `CATALOGUE` — under which only this fails, and it /// fails by saying a release that promised to add no configuration added one. +/// +/// **0.40.0 is the release that moved it, and it moved it by thirteen.** The +/// number stays written out and the reason is written beside it, because the +/// point of the gate is that growing this surface is a decision recorded here +/// rather than a line added elsewhere. Each of the thirteen was already +/// reachable by hand-editing the file and each was mis-typed by the shell door; +/// none of them is new configuration. `browser.binary` is deliberately not among +/// them — it is on `configure::EXCLUDED`, which `tests/docs.rs` holds to covering +/// every leaf this list does not. #[test] -fn n3_this_release_adds_no_configuration_key() { - let before_0_27_0 = 37; +fn n3_the_key_catalogue_grows_only_when_a_release_says_so() { + let through_0_39_0 = 37; + let added_by_0_40_0 = 13; assert_eq!( io_cli::configure::CATALOGUE.len(), - before_0_27_0, - "0.27.0 adds three commands and no keys; a different number here means a \ - release that promised an operator nothing would change gave them \ - something to configure", + through_0_39_0 + added_by_0_40_0, + "a different number here means a release changed what an operator has to \ + configure without saying so. 0.40.0 added thirteen rows — \ + `reference_catalogue`, `prices.models`, the five browser keys it offers \ + and the six containment ceilings.", ); } From 2b6d8e1d8e0e7266cca440a6b2d36bfd06924972 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Mon, 7 Sep 2026 08:22:44 +0530 Subject: [PATCH 4/9] fix(contract): a [run] max_steps a file named beats the gated cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gated_bound` tested "the operator set no step cap" by asking whether io-cli's own floor of a thousand had survived `Config::apply_to`. That call sets the field unconditionally when the key is present, so `[run] max_steps = 1000` — the documented default, written down — was indistinguishable from having written nothing, and a gated headless run took forty steps against a file asking for a thousand. The one number an operator is most likely to write was the one value that did nothing. The comment here said io-harness's `run` section is private so there was no way to ask whether the key had been written. The section is private and the question is answerable: `Config::origin` names the files that set a key, its own doctest is `run.max_steps`, and `/config` has told a file value from a default through it since 0.28.0. The row that pinned the collision as a chosen cost is rewritten rather than deleted, and it moved: `Config::from_toml` has no file and therefore no origins, so `tests/gates.rs` cannot tell the two cases apart and says so rather than pretending. F5 lives in `tests/contract.rs`, over a real discovered `io.toml`, with the companion proving the floor still caps a gated run that wrote nothing. The forty-step cap and the four other sentences this release corrects are documented and gated: the commit identity read from `Identity::default()` rather than typed, the cap named in the headless guide, the `--json` reasoning-order caveat, and the two stale widening claims. `io.local.toml` has been widening-checked since io-harness 0.74.0 and three pages still offered it as the place to put a widening value — including the permissions skill, which is what advised the field tester into the refusal they hit — and the refused list is thirteen pairs across twelve keys, not five. --- docs/config.example.toml | 52 +++++++---- docs/guide/configuration.md | 58 +++++++----- docs/guide/git.md | 11 ++- docs/guide/headless.md | 30 +++++- skills/io-permissions.md | 11 ++- src/contract.rs | 48 ++++++---- src/exec.rs | 4 +- src/main.rs | 15 +++ tests/contract.rs | 48 ++++++++++ tests/docs.rs | 177 ++++++++++++++++++++++++++++++++++++ tests/gates.rs | 30 +++--- tests/undo.rs | 77 ++++++++++++++++ 12 files changed, 471 insertions(+), 90 deletions(-) diff --git a/docs/config.example.toml b/docs/config.example.toml index 571ef15..72117ea 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -70,25 +70,42 @@ model = "anthropic/claude-sonnet-4" # otherwise. In a headless `io exec` run `ask` is still a decline, because nothing # there can answer it — see the README. # -# NOTE: a PROJECT-scoped file may narrow this and may never widen it — a +# NOTE: a file INSIDE THE WORKSPACE 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: -# +# THAT MEANS io.local.toml TOO, SINCE io-harness 0.74.0. The rule used to be +# about the committed file alone; it is now about any file inside the workspace +# root, because io.local.toml is a path the run's own agent can write to — so one +# write_file of it would have declared an argv the next discovery runs, outside +# the policy and outside the sandbox. The user scope is the one file no workspace +# can reach. +# +# THERE ARE THIRTEEN (KEY, VALUE) PAIRS ACROSS TWELVE KEYS THAT A FILE IN THE +# WORKSPACE 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.read = "allow" +# policy.defaults.write = "allow" # 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. +# sandbox.mode = "full-access" (and "workspace-write") +# sandbox.limits.max_cpu_secs = 0 +# sandbox.limits.max_wall_secs = 0 +# sandbox.limits.max_memory_bytes = 0 +# sandbox.limits.max_processes = 0 +# sandbox.limits.max_open_files = 0 +# +# The five limits are refused only at ZERO, which is what "no cap" means — a +# merely large finite cap is still a cap. The narrowing value of each key stays +# legal in a workspace file, which is what the scope is for; `read-only` is the +# sandbox mode that narrows in every direction and stays legal everywhere. Write +# any of these in THIS user-scope file. `/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 @@ -218,11 +235,12 @@ net = "deny" # 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 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. +# false` and `mode = "full-access"` are three of the thirteen pairs a file inside +# the workspace may not write — io.local.toml included — 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 diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index eb7d259..7ed05c6 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -108,25 +108,32 @@ an MCP server id — is addressed correctly too; it could only be written quoted and the path splitter cut it in half, which surfaced as an unexplainable "the edit would have produced a file that does not parse". -**A project-scoped change that would widen the boundary is refused in -io-harness's own words**, and the same value is accepted in `io.local.toml` — -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. +**A change that would widen the boundary is refused, in io-harness's own words, +from any file inside the workspace** — the project's `io.toml` and +`io.local.toml` alike. The rule is about which file, not which value, and since +io-harness 0.74.0 the local file is on the wrong side of it too: it sits in a +root the run's own agent can write to, so a single `write_file` of it would +declare an argv the next discovery runs, outside the policy and outside the +sandbox. The user scope is the one file no workspace can reach. 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. +key. There are thirteen (key, value) pairs across twelve keys that a file in the +workspace may not carry — the four `policy.defaults.*` acts set to `"allow"`, +`sandbox.allow_network = true`, `sandbox.force_floor = false`, `sandbox.mode` set +to either `"full-access"` or `"workspace-write"`, and each of the five +`sandbox.limits.*` set to `0`, which is what "no cap" means. io-harness's check +runs *before* the file is deserialized, so choosing one of them in a workspace +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 scope that +will take the value: `--scope user`, for yourself. **From 0.40.0 the `/config` +scope picker offers only that scope for such a key**, rather than offering three +and letting the write refuse two of them. **`/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 @@ -452,10 +459,11 @@ file of that name, both are left where they are and the session says which one i in force. To keep the location you have, set `IO_CONFIG_HOME` to it before the first 0.15.0 run. -One thing worth knowing: a **project** file may narrow the permission boundary -and may never widen it, because a repository you cloned must not be able to grant -itself permission. The wizard therefore writes the user-scope file, which is -where widening is your own decision. +One thing worth knowing: a file **inside the workspace** may narrow the +permission boundary and may never widen it, because a repository you cloned must +not be able to grant itself permission — and `io.local.toml` counts, since +io-harness 0.74.0, because the agent can write to it. The wizard therefore writes +the user-scope file, which is where widening is your own decision. The policy's own defaults are what `Shift+Tab` cycles; a posture chosen with the key lasts for the session and is not written back, because a keystroke that @@ -508,11 +516,11 @@ decides what this machine may reach. A `[web]` table that did nothing in your terminal yesterday turns something on in it today, which is why the session says so at start in its own words rather than folding it into a list. -**`[browser]` is refused in a project-scoped file**, by io-harness rather than by -io-cli: it names a program to execute, and a project's `io.toml` arrives with a -`git clone`. Write it in the user-scope file — the one `io setup` writes — where -widening the boundary is your own decision. There is no project-scope route to a -browser at all. io-cli's own `[app.io-cli.browser]` is read from either scope. +**`[browser]` is refused in any file inside the workspace**, by io-harness rather +than by io-cli: it names a program to execute, a project's `io.toml` arrives with +a `git clone`, and `io.local.toml` is a path the agent can write. Write it in the +user-scope file — the one `io setup` writes — where widening the boundary is your +own decision. There is no workspace-scope route to a browser at all. io-cli's own `[app.io-cli.browser]` is read from either scope. `NO_COLOR` is read from the environment rather than from this file, and so is the locale behind `glyphs`. See [Reading it without seeing diff --git a/docs/guide/git.md b/docs/guide/git.md index 1f93bc4..709b83d 100644 --- a/docs/guide/git.md +++ b/docs/guide/git.md @@ -34,9 +34,14 @@ deterministic act. reads that value rather than picking one. io-harness hands the name and email from that section to git on the commit invocation itself, and the section always resolves to something, so a repository with no identity of its own is told which -default io-harness will use. You are told before the turn is spent, because the -author of a commit is the one thing about it that cannot be corrected afterwards -without rewriting history. +default io-harness will use. **That default is `io-harness agent +`** — named here rather than left to be discovered in +`git log`, because until you have seen it you have no way to know what your +history will carry. The address is deliberately unreachable: `.invalid` is +reserved by RFC 2606 precisely so a synthetic address cannot accidentally be +somebody's. You are told before the turn is spent, because the author of a commit +is the one thing about it that cannot be corrected afterwards without rewriting +history. ### The refusal this repairs — and the half of it that is now fixed upstream diff --git a/docs/guide/headless.md b/docs/guide/headless.md index 0945967..68ab34a 100644 --- a/docs/guide/headless.md +++ b/docs/guide/headless.md @@ -107,7 +107,24 @@ a repository and writes a file spends them easily — so what an unattended job produced was `error: step_cap_reached` over half-finished work with nobody watching, which is the defect the floor exists to fix and is not made better by the run being unattended. A `[run] max_steps` you wrote still beats the floor, in -either direction. +either direction — a file that *lowers* the cap is honoured, not only one that +raises it. + +**A gated run with no budget is capped at forty steps, and from 0.40.0 anything +you wrote beats that too.** A gate is the one configuration that takes away every +bound the floor of a thousand is safe because of: io-harness evaluates the +criterion after every step and hands a failure back, which is a reason to keep +going; a run with no budget is measured against nothing; and headless has no +operator to stop it. So a `[app.io-cli.gates]` run that set no `max_steps`, no +`max_duration_secs` and no `max_tokens` stops at forty and exits `3` naming the +ceiling. Forty is chosen to leave room for several write-then-check cycles while +ending a hopeless run in the minutes an unattended job can afford. + +Through 0.39.0 there was one value that collided with the floor: `[run] max_steps += 1000` is the default written down, so it was indistinguishable from having +written nothing and such a run took forty. **It is honoured from 0.40.0**, which +is a behaviour change for exactly that configuration — a job that wrote a +thousand and relied on the forty-step cap to bound it now runs to a thousand. ### The JSON @@ -127,6 +144,17 @@ renderer has no way to draw. **There is no timestamp**: `RunEvent` does not carry one, and inventing an envelope to add one would make this a format io-cli owns rather than one it passes through. +**The order is the harness's, and one pair of it will read backwards.** A +`reasoning` event arrives *after* the `step` whose tokens it explains, because +that is the order io-harness emits them and this stream relays what it is handed +rather than buffering to re-sort it. A consumer that renders in arrival order +shows a thought under the step it preceded. Nothing documented is broken by it — +the events carry `run_id` and `step`, so a reader that groups by step gets it +right — but a reader that assumes arrival order is chronological within a step +will not. The interactive renderer's own ordering was corrected in 0.38.1; the +stream deliberately was not, because re-ordering it would mean holding events back +and this door exists to hand them over as they happen. + Progress, warnings and the closing summary go to stderr, so redirecting it leaves the data alone. diff --git a/skills/io-permissions.md b/skills/io-permissions.md index 2873c1f..ca39c4a 100644 --- a/skills/io-permissions.md +++ b/skills/io-permissions.md @@ -42,10 +42,13 @@ project on this machine), the project's own `io.toml` (committed, everyone who clones it), and `io.local.toml` beside it (this checkout, not committed). "In this repository" means the project file or the local one, never the user -file. io-harness **refuses** a project-scoped change that widens the boundary, -in its own words, and accepts the same value in `io.local.toml` — the rule is -about which file, not which value. So an uncommitted widening belongs in -`io.local.toml` and there is no argument to have about it. +file. io-harness **refuses** a change that widens the boundary from either of +them, in its own words — the rule is about which file, not which value, and since +io-harness 0.74.0 `io.local.toml` is on the refused side too, because it is a +path inside the workspace that the run's own agent can write to. **A widening +therefore belongs in the user-scope file and nowhere else**, which is the one +file no workspace can reach. Telling somebody to put it in `io.local.toml` sends +them into a refusal. ## The surface diff --git a/src/contract.rs b/src/contract.rs index e334a30..9d6b6b8 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -145,25 +145,26 @@ pub const GATED_MAX_STEPS: u32 = 40; /// either of those, which is why the bound has to be a property of the contract /// there and need not be here. /// -/// **The test for "the operator set no step cap" is that the floor survived.** -/// `configured` calls `with_max_steps(MAX_STEPS)` *before* `Config::apply_to`, so -/// a `[run] max_steps` in the file has already overwritten it by the time this -/// runs — the same reading `crate::status` takes of the same field, rather than a -/// second question put to the configuration. -/// -/// **It has one collision and it is stated rather than hidden: `[run] max_steps = -/// 1000` reads as "no cap set".** `Config::apply_to` calls `with_max_steps` -/// unconditionally when the key is present, so an explicit thousand is -/// indistinguishable from the floor, and a gated run configured that way takes -/// forty. io-harness's `run` section is private, so there is no way to ask -/// whether the key was written — only what it produced. The cost is bounded and -/// one-directional: an operator who meant a thousand gets forty and sees exit `3` -/// naming the ceiling, which is diagnosable and one edit from fixed; any other -/// number, including `1001`, behaves exactly as written. Asserted as a row in -/// `f3_a_gated_headless_run_with_no_budget_takes_the_gated_cap` so it is a chosen -/// cost rather than an unnoticed one. Found by the adversarial review. +/// **The test for "the operator set no step cap" is two questions, and it used to +/// be one.** The first is that the floor survived: `configured` calls +/// `with_max_steps(MAX_STEPS)` *before* `Config::apply_to`, so a `[run] max_steps` +/// in the file has already overwritten it by the time this runs. The second is +/// whether a file named the key at all, which is what [`Config::origin`] answers. +/// +/// **The collision this used to state as a chosen cost is closed (0.40.0), and it +/// was never a cost worth choosing.** `[run] max_steps = 1000` read as "no cap +/// set", because `Config::apply_to` calls `with_max_steps` unconditionally when +/// the key is present and an explicit thousand is indistinguishable from the +/// floor — so a gated run configured that way took forty steps against a file +/// asking for a thousand, and the number an operator was most likely to write was +/// the one value that did nothing. The old comment here said io-harness's `run` +/// section is private so there was no way to ask whether the key was written. The +/// section is private and the question is still answerable: `Config::origin` +/// names the files that set a key and is empty for one no file names, its own +/// doctest is `run.max_steps`, and `/config` has told a file value from a default +/// through it since 0.28.0. #[must_use] -pub fn gated_bound(contract: TaskContract) -> TaskContract { +pub fn gated_bound(contract: TaskContract, config: &Config) -> TaskContract { // **The contract's own criterion, never the configuration's.** The first // draft asked `criterion_of(config)`, and the adversarial review found that // it answers a different question than the one that matters: @@ -180,7 +181,16 @@ pub fn gated_bound(contract: TaskContract) -> TaskContract { // actually run, put there by the same call that built everything else here. let ungated = matches!(contract.verify, io_harness::Verification::None); let budgeted = contract.max_duration.is_some() || contract.max_tokens.is_some(); - if ungated || budgeted || contract.max_steps != MAX_STEPS { + // **The one value an operator is most likely to write used to read as unset + // (0.40.0).** `Config::apply_to` calls `with_max_steps` unconditionally when + // the key is present, so `[run] max_steps = 1000` produced a contract + // indistinguishable from io-cli's own floor and a gated run took forty steps + // against a file that asked for a thousand. The comment that stood here said + // there was no way to ask whether the key had been written; there is, and + // `/config` has used it since 0.28.0 to tell a file value from a default. + // `Config::origin` is empty for a key no file names. + let wrote_it = !config.origin("run.max_steps").is_empty(); + if ungated || budgeted || wrote_it || contract.max_steps != MAX_STEPS { return contract; } contract.with_max_steps(GATED_MAX_STEPS) diff --git a/src/exec.rs b/src/exec.rs index d6b88b6..c77dcc3 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -758,7 +758,7 @@ pub fn contract( // operator watching either way; headless has neither, so the bound has to // ride the contract. See `contract::gated_bound` for why a gate takes away // every fallback `contract::MAX_STEPS` is safe because of. - let contract = crate::contract::gated_bound(contract); + let contract = crate::contract::gated_bound(contract, config); // The flag last, so it beats the file, and applied with `with_exec_mode` // rather than by replacing the whole `SandboxConfig` — the limits the file // set are the operator's and are not this flag's to discard. @@ -1753,7 +1753,7 @@ impl WithProvider for Resuming { // `Died` is carried on rather than refused. Bounding one door and not the // other would have fixed the walk to a thousand steps only until somebody // typed the obvious next command. - let contract = crate::contract::gated_bound(contract); + let contract = crate::contract::gated_bound(contract, &self.config); // `None` on every arm: a containment is a fleet's shared budget, this // subcommand takes no flag that expresses one, and `crate::resume::recover` // refuses a contained run outright because io-harness publishes no diff --git a/src/main.rs b/src/main.rs index f9b8079..1e45090 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9091,6 +9091,21 @@ fn undo_whole_turn( for (tone, line) in io_cli::rewind::undone_lines(&undone, &app.theme.glyphs) { app.record(tone, line); } + // **The thought goes too (0.40.0).** This cleared the status, the + // fleet and the seen set and never touched `Events`, so after an undo + // `/expand` still printed the undone turn's reasoning — the one thing + // an operator undoes a turn to be rid of, kept by the act meant to + // remove it. `App::undo_turn`, the mid-turn abandon path, has always + // called both; two implementations of one act is exactly what this + // function's own comment warns about, and this is the half that was + // wrong. + // + // **After the record loop, not before.** `Events::forget` also resets + // the blank-line state that loop writes through, so forgetting first + // would run the undo's own report through a renderer that had just + // been told the scrollback was empty. + app.events.forget(); + app.servers.forget(); } Ok(None) => app.record(Tone::Muted, "there is no turn to undo".to_string()), // **`failure::said`, and not the raw `Display`.** Since 0.23.0 the undo diff --git a/tests/contract.rs b/tests/contract.rs index beeed87..e9f0571 100644 --- a/tests/contract.rs +++ b/tests/contract.rs @@ -2093,6 +2093,54 @@ fn f11_asking_for_the_default_explicitly_changes_nothing() { assert!(built.detached_spawns); } +/// **F5 — a `[run] max_steps` a file named beats the gated cap.** +/// +/// The one number an operator is most likely to write was the one value that did +/// nothing. `configured` applies io-cli's own floor of a thousand before +/// `Config::apply_to` runs, and `apply_to` sets the field unconditionally when +/// the key is present — so `[run] max_steps = 1000` produced a contract +/// indistinguishable from having written nothing, and a gated headless run took +/// forty steps against a file asking for a thousand. +/// +/// **Through a real `io.toml`, because the whole question is whether a file named +/// the key.** `Config::from_toml` has no file and therefore no origins, which is +/// exactly why `tests/gates.rs` cannot hold this row and points here instead. +/// +/// Sabotage: drop the `origin` check from `contract::gated_bound`. The first +/// assertion goes red and the second stays green. +#[test] +fn f5_a_max_steps_a_file_named_beats_the_gated_cap() { + let _guard = env_lock(); + let gate = "[app.io-cli.gates]\ncommand = [\"false\"]\nexpect_exit = 0\n"; + + let (dir, config) = discovered(&[( + "io.toml", + &format!( + "{gate}\n[run]\nmax_steps = {}\n", + io_cli::contract::MAX_STEPS + ), + )]); + let contract = + io_cli::contract::configured("goal", dir.path().to_path_buf(), &config, &config.plugins()); + assert_eq!( + io_cli::contract::gated_bound(contract, &config).max_steps, + io_cli::contract::MAX_STEPS, + "a thousand steps an operator wrote in a file is a thousand steps they get", + ); + + // The companion: the floor still caps a gated run that wrote nothing, which is + // the behaviour 0.38.1 added and this release must not have removed while + // making the value above mean what it says. + let (dir, config) = discovered(&[("io.toml", gate)]); + let contract = + io_cli::contract::configured("goal", dir.path().to_path_buf(), &config, &config.plugins()); + assert_eq!( + io_cli::contract::gated_bound(contract, &config).max_steps, + io_cli::contract::GATED_MAX_STEPS, + "a gated run the operator gave no budget at all is still bounded", + ); +} + /// **N3 — 0.27.0 adds no configuration key, so an operator who runs none of it /// gets the contract 0.26.0 built.** /// diff --git a/tests/docs.rs b/tests/docs.rs index 91abf66..eacbf18 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -3472,3 +3472,180 @@ fn f9_plugin_search_with_no_match_says_nothing_matched() { agreeing only until one of them is edited", ); } + +// --------------------------------------------------------------------------- +// N2 — every sentence 0.40.0 corrected has a gate that names a phrase a reader +// would act on +// --------------------------------------------------------------------------- + +/// **N2 — the git guide names the identity a commit will carry.** +/// +/// The page explained that the identity always resolves and that you are told +/// which default io-harness will use, and never said what it is — so an operator +/// could not know their history would carry `io-harness agent` until it did, at +/// which point the one fact about a commit that cannot be corrected without +/// rewriting history was already wrong. +/// +/// **Read from `Identity::default()` rather than typed here**, the way +/// `tests/commit.rs` already does it: a literal in this file is a second place +/// for the string to live, and the wrong one would still pass. +#[test] +fn n2_the_git_guide_names_the_commit_identity_io_harness_will_use() { + let default = io_harness::Identity::default(); + let page = guide("git"); + for part in [&default.name, &default.email] { + assert!( + page.contains(part.as_str()), + "docs/guide/git.md says a repository with no identity is told which default \ + io-harness will use and never names it. The default is `{}` <{}>, and until \ + an operator has seen it they have no way to know what their history will \ + carry.", + default.name, + default.email, + ); + } +} + +/// **N2 — the headless guide names the gated step cap.** +/// +/// `contract::GATED_MAX_STEPS` bounds a gated headless run that budgeted nothing, +/// it decides whether an unattended job finishes, and through 0.39.0 it appeared +/// in no shipped document at all — the only place the number and its reasoning +/// existed was a doc comment on the constant. +#[test] +fn n2_the_headless_guide_names_the_gated_step_cap() { + let page = guide("headless"); + let cap = io_cli::contract::GATED_MAX_STEPS; + assert_eq!( + cap, 40, + "the cap moved and the word below is spelled out, so the assertion has to \ + move with it rather than silently checking for the old number", + ); + for needle in [&cap.to_string(), "forty"] { + assert!( + page.contains(needle), + "docs/guide/headless.md does not name the gated step cap ({needle}), which \ + is what decides whether an unattended gated run finishes", + ); + } + assert!( + page.contains("exits `3`"), + "a cap an operator meets has to name the exit code they will see", + ); +} + +/// **N2 — the headless guide warns that `reasoning` trails the step it explains.** +/// +/// The `--json` stream relays io-harness's order faithfully and breaks nothing +/// documented — the events carry `run_id` and `step`, so a reader that groups by +/// step is right. A reader that assumes arrival order is chronological is not, and +/// the screen ordering having been corrected in 0.38.1 is exactly what makes the +/// stream's order surprising. +#[test] +fn n2_the_json_section_warns_that_reasoning_trails_its_step() { + let page = guide("headless"); + assert!( + page.contains("`reasoning` event arrives *after* the `step`"), + "docs/guide/headless.md documents the JSON stream and does not say that a \ + thought arrives after the step whose tokens it explains, so a consumer that \ + renders in arrival order shows it under the wrong step", + ); +} + +/// **N2 — no shipped page still says a widening value is accepted in +/// `io.local.toml`.** +/// +/// Untrue since io-harness 0.74.0, which widening-checks that file always: it is a +/// path inside the workspace root that the run's own agent can write to, so a +/// single `write_file` of it would declare an argv the next discovery runs, +/// outside the policy and outside the sandbox. +/// +/// **`skills/io-permissions.md` is the one that mattered.** It is read by an agent +/// and it is what advised the 2026-09-05 field tester into the refusal they hit — +/// a page that sends somebody somewhere it knows they will be refused. +#[test] +fn n2_no_shipped_page_offers_the_local_file_as_the_place_to_widen() { + let mut pages = shipped_prose(); + pages.push(( + "docs/config.example.toml".to_string(), + read("docs/config.example.toml"), + )); + + for (name, text) in &pages { + for stale in [ + "accepted in `io.local.toml`", + "accepts the same value in `io.local.toml`", + "belongs in\n`io.local.toml`", + "in io.local.toml for this checkout", + "`--scope local` for this checkout", + ] { + assert!( + !text.contains(stale), + "{name} still offers `io.local.toml` as a place a widening value is \ + taken. io-harness has refused that file since 0.74.0, so the sentence \ + sends an operator into a refusal: {stale:?}", + ); + } + } + + // And the three pages that carry the rule say what it is now, so the negative + // sweep above cannot be satisfied by deleting the subject. + for name in [ + "docs/guide/configuration.md", + "docs/config.example.toml", + "skills/io-permissions.md", + ] { + let text = read(name); + assert!( + text.contains("io.local.toml") && text.contains("0.74.0"), + "{name} explains where a widening value goes and does not say that \ + `io.local.toml` has been refused since io-harness 0.74.0", + ); + } +} + +/// **N2 — no shipped page counts five refused pairs.** +/// +/// There are thirteen, across twelve keys: the four `policy.defaults.*` acts, two +/// sandbox flags, two `sandbox.mode` values and the five `sandbox.limits.*` zeroes. +/// The count is io-harness's own `PROJECT_WIDENING`, and it is +/// `tests/configure.rs`'s `f2_a_widening_value_is_legal_in_one_scope_and_refused_in_another` +/// that holds the *code* to it by driving every pair through the real refusal — +/// this gate is only about the prose beside it, which had drifted eight pairs +/// short and was gated by nothing at all. +#[test] +fn n2_no_shipped_page_counts_five_refused_widening_pairs() { + let mut pages = shipped_prose(); + pages.push(( + "docs/config.example.toml".to_string(), + read("docs/config.example.toml"), + )); + + for (name, text) in &pages { + for stale in [ + "exactly five (key, value) pairs", + "EXACTLY FIVE (KEY, VALUE) PAIRS", + "three of the five pairs", + "any of the five in", + ] { + assert!( + !text.contains(stale), + "{name} says there are five refused (key, value) pairs. There are \ + thirteen, and a reader who trusts the short list writes one of the \ + other eight into a file that then does not parse at all: {stale:?}", + ); + } + } + + // Lowercased on both sides: the example file shouts this sentence in capitals, + // which is its own convention for a rule with a cost, and a gate that forced + // one casing would be editing the page's voice rather than its facts. + for name in ["docs/guide/configuration.md", "docs/config.example.toml"] { + let text = read(name).to_lowercase(); + assert!( + text.contains("thirteen (key, value) pairs"), + "{name} lists what a workspace file may not widen and does not say how \ + many there are", + ); + } +} diff --git a/tests/gates.rs b/tests/gates.rs index f15754e..7dbad9c 100644 --- a/tests/gates.rs +++ b/tests/gates.rs @@ -886,7 +886,7 @@ fn f3_a_gated_headless_run_with_no_budget_takes_the_gated_cap() { let build = |config: &Config| { let contract = io_cli::contract::configured("goal", workspace.clone(), config, &config.plugins()); - io_cli::contract::gated_bound(contract).max_steps + io_cli::contract::gated_bound(contract, config).max_steps }; assert_eq!( @@ -919,27 +919,19 @@ fn f3_a_gated_headless_run_with_no_budget_takes_the_gated_cap() { ); } - // **The one colliding value, asserted so it is a chosen cost.** `[run] - // max_steps = 1000` is the documented default written down, and - // `Config::apply_to` sets the field unconditionally — so it is - // indistinguishable from the floor `configured` applied, and the gated run - // takes forty. io-harness's `run` section is private, so nothing here can ask - // whether the key was present; this row exists so that the behaviour is - // recorded rather than discovered. Any other number, `1001` included, is - // honoured as written — which the row below proves, so this is a collision at - // exactly one value and not a rule. - assert_eq!( - build(&gated(&format!( - "[run]\nmax_steps = {}\n", - io_cli::contract::MAX_STEPS - ))), - io_cli::contract::GATED_MAX_STEPS, - "writing the default explicitly is indistinguishable from not writing it", - ); + // **The collision at exactly the floor is F5's, and it is asserted where a + // real file exists (0.40.0).** `[run] max_steps = 1000` used to read as "no + // cap set", because `Config::apply_to` sets the field unconditionally and an + // explicit thousand is indistinguishable from the floor `configured` applied. + // What distinguishes them is `Config::origin`, which names the files that set + // a key — and `Config::from_toml` has no file, so this fixture cannot tell the + // two apart and must not pretend to. The rows that do are + // `f5_a_max_steps_a_file_named_beats_the_gated_cap` and its companion in + // `tests/contract.rs`, which discover a real `io.toml`. assert_eq!( build(&gated("[run]\nmax_steps = 1001\n")), 1001, - "and one step either side of the collision is honoured as written", + "and one step either side of it is honoured as written, as it always was", ); } diff --git a/tests/undo.rs b/tests/undo.rs index b324030..1867f51 100644 --- a/tests/undo.rs +++ b/tests/undo.rs @@ -445,6 +445,83 @@ fn the_file_confirmation_discloses_what_a_restore_overwrites() { assert!(detail.contains("overwritten"), "{detail}"); } +/// **F4 — `/undo` leaves nothing of the turn behind.** +/// +/// The half that runs. `Events::forget` is what `/expand` reads through, so an +/// undo that does not call it leaves the undone turn's thought printable — the one +/// thing an operator undoes a turn to be rid of, kept by the act meant to remove +/// it. +#[test] +fn f4_forgetting_a_turn_takes_the_thought_expand_would_print() { + let mut events = io_cli::events::Events::new(io_cli::theme::DARK); + let rendered = events.event( + &io_harness::RunEvent::new( + 1, + 1, + io_harness::EventKind::Reasoning { + text: "the parser is where this belongs".into(), + tokens: 40, + }, + ), + std::time::Duration::ZERO, + ); + assert!(!rendered.is_empty(), "a thought commits its one row"); + assert_eq!( + events.thought(), + Some("the parser is where this belongs"), + "`/expand` has the thought to print before the undo", + ); + + events.forget(); + assert_eq!( + events.thought(), + None, + "after the turn is forgotten, `/expand` has nothing of it left to print", + ); +} + +/// **F4's other half — both undo paths call it.** +/// +/// A source-text gate, and said plainly rather than dressed up as a behavioural +/// test: the whole-turn undo's call site is in `src/main.rs`, which no test binary +/// links, so a guard written there cannot be run and cannot be sabotaged. What can +/// be asserted is that the call is in each of the two functions that take a turn +/// back — the driver's `undo_whole_turn` and `App::undo_turn`, the mid-turn +/// abandon path, which has always been the one that got it right. +/// +/// Sliced by function rather than counted over the file, because `src/app.rs` has +/// a second `events.forget()` on a different act and a count would pass with the +/// undo path missing its own. +/// +/// Sabotage: delete either call. The naming half of this goes red. +#[test] +fn f4_both_undo_paths_forget_the_events() { + let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + for (file, opening) in [ + ("src/main.rs", "fn undo_whole_turn("), + ("src/app.rs", "pub fn undo_turn("), + ] { + let text = std::fs::read_to_string(repo.join(file)).expect("the source is readable"); + let from = text + .find(opening) + .unwrap_or_else(|| panic!("{file} declares `{opening}`")); + // To the end of the function, which at these two sites is the first line + // that closes a block at the declaration's own indentation. + let body = &text[from..]; + let closing = if file == "src/app.rs" { + "\n }" + } else { + "\n}" + }; + let body = &body[..body.find(closing).expect("the function closes")]; + assert!( + body.contains("events.forget()"), + "{file}'s `{opening}` takes a turn back and never forgets what the turn \ + rendered, so `/expand` still prints the undone turn's thought", + ); + } +} + /// `Grain` is a description of what was asked for and nothing more. /// /// It carries no `label()`: the confirmations build their own sentences and the From 95b726564ccd98e5e554b4c4a5f0040770bc0a29 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Mon, 7 Sep 2026 08:30:50 +0530 Subject: [PATCH 5/9] feat(acp, config): an editor may attach, and the scope picker offers only what will be taken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two `/config` doors disagreed. The value arm has checked `widens_workspace` since 0.28.0 and refuses before it writes; the scope arm offered all three files and let the round trip refuse two of them afterwards — so an operator choosing `sandbox.mode = "full-access"` was shown `io.toml` and `io.local.toml` as places to put it, picked one, and met a refusal the surface already knew was coming. And the refusal is of the whole file, not of the key, which is the worst moment to learn the rule. `configure::writable_scopes` is the decision, in the library, because nothing under `tests/` links `src/main.rs`. The round trip stays as the backstop and is not redundant: it is what restores "absent" rather than "empty" for every refusal the predicate does not model — a refused `[browser]` section is not a widening value, and that arm is what covers it. ACP declares `image` and `embeddedContext`. A `resource` block carries the text of the file an editor has open and was dropped silently, so the model was asked about a file it had never been given; it is folded into the prompt with its uri now, and a `resource_link` is folded as the mention it is — never read here, which would be a file access outside the session's own policy made by the adapter instead of by the agent. An `image` block goes through `Media::attach`, the same door `/attach` uses, so it gets io-harness's media-type table, its pixel-bomb guard, its byte bound and its conversion of the five formats a provider will not take. Constructing a `Media` out of the frame's two fields would have been shorter and skipped every one of those, so the base64 decode is fifteen lines of table lookup rather than an eleventh dependency. A block that cannot be taken is reported to the client as a message: an editor that attached one unreadable screenshot must not lose its question, and must not get an answer written without the picture and no word about it. `audio` stays false. io-harness's `Media` has an image media type and nothing else, so there is nothing one layer down to carry the bytes and declaring it would be a promise this product cannot keep. --- docs/guide/editors.md | 14 ++++ src/acp.rs | 189 ++++++++++++++++++++++++++++++++++++++---- src/configure.rs | 34 ++++++++ src/main.rs | 12 +-- tests/acp.rs | 145 ++++++++++++++++++++++++++++++-- tests/configure.rs | 101 ++++++++++++++++++++++ 6 files changed, 466 insertions(+), 29 deletions(-) diff --git a/docs/guide/editors.md b/docs/guide/editors.md index 4c52f0a..6430dc0 100644 --- a/docs/guide/editors.md +++ b/docs/guide/editors.md @@ -113,6 +113,20 @@ so loading is reopening that conversation; its turns are replayed to you as the `session/update` notifications a live turn sends, which is why your client renders a loaded conversation with the code it already renders a running one with. +**Attaching what you are looking at.** From 0.40.0 `image` and `embeddedContext` are declared, so a +client will offer both. An `image` block reaches the model as an attachment on that one turn, put +through the same door `/attach` uses — io-harness's own media-type table, its size bound and its +conversion of the formats a provider will not take — and a block it cannot take is reported back to +you as a message rather than dropped, so a screenshot that did not arrive says so instead of +producing an answer written without it. A `resource` block is folded into the prompt with its uri, +which is how an editor hands over the file you have open; a `resource_link` is folded as the mention +it is, and the agent reads it for itself through the tool your policy governs rather than the adapter +reading it around that policy. + +`audio` is declared **false** and will stay false until io-harness carries audio: `Media` has an +image media type and nothing else, and the crate renders an audio file as a named non-attachment. +Declaring it would be a promise `io` cannot keep whatever this adapter does. + What is replayed is what was **said** — your prompts and the agent's replies, along the path the conversation actually took. A turn you rewound is not replayed: undoing moves the head without deleting the turn, so the stored tree still contains it, and showing it back to you would be diff --git a/src/acp.rs b/src/acp.rs index 9322c99..7615cc0 100644 --- a/src/acp.rs +++ b/src/acp.rs @@ -514,9 +514,20 @@ impl Default for AgentCapabilities { Self { load_session: true, prompt_capabilities: PromptCapabilities { - image: false, + // Both true from 0.40.0. `image` is `Media::attach` behind a + // base64 decode; `embeddedContext` is text folded into the + // prompt. Neither is a new seam — see [`prompt_text`] and + // [`prompt_images`]. + image: true, + embedded_context: true, + // **`audio` stays false, and io-harness is the reason rather + // than an unfinished task.** `Media` has an image media type and + // nothing else (`IMAGE_MEDIA_TYPES` is its whole vocabulary), and + // the crate renders an audio file as a named non-attachment. A + // capability declared here is a promise this product cannot keep + // whatever it does, because what would carry the bytes does not + // exist one layer down. audio: false, - embedded_context: false, }, } } @@ -1256,7 +1267,24 @@ impl Editing<'_, P> { ErrorCode::InvalidParams, "a prompt carries at least one text content block".to_string(), ))?; - let reason = self.run(&session_id, goal).await; + // **Refused images are said, never swallowed.** They go out as + // agent-message text on the session's own stream, because a client + // that attached a screenshot and got an answer written without it + // has no other way to learn that happened. + let (images, refused) = prompt_images(params); + for sentence in refused { + let _ = self.outbound.send(Outgoing::Notification { + method: "session/update".into(), + params: json!({ + "sessionId": session_id, + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": sentence }, + }, + }), + }); + } + let reason = self.run(&session_id, goal, images).await; Ok(json!({ "stopReason": reason })) } _ => Err(( @@ -1278,7 +1306,12 @@ impl Editing<'_, P> { } /// Drive one turn, streaming updates, and answer with its stop reason. - async fn run(&mut self, session_id: &str, goal: String) -> &'static str { + async fn run( + &mut self, + session_id: &str, + goal: String, + images: Vec, + ) -> &'static str { let (reporter, shared) = Reporter::new(session_id, self.outbound.clone()); self.cancel = Some(std::sync::Arc::clone(&shared.cancelled)); @@ -1290,6 +1323,13 @@ impl Editing<'_, P> { let Some(mut session) = self.sessions.remove(session_id) else { return "refusal"; }; + // **The same call `/attach` makes, and it is io-harness that carries them + // onto the turn.** `Session::attach` holds the images for the next turn + // only, which is exactly what an ACP prompt means: the client sent them + // with this question and not with the conversation. + if !images.is_empty() { + session.attach(images); + } let contract = crate::exec::contract(self.config, &session, goal, None); let run_id = session.id(); let outcome = session @@ -1348,21 +1388,140 @@ impl Editing<'_, P> { /// The text of a `session/prompt`, joined from its content blocks. /// -/// ACP carries a prompt as a list of typed blocks. 0.36.0 declares no image, -/// audio or embedded-context capability, so a conforming client sends text — -/// and anything else is skipped rather than rendered as a placeholder the model -/// would try to read. +/// ACP carries a prompt as a list of typed blocks. **From 0.40.0 the embedded +/// ones are folded in rather than dropped**: an editor that sends a file the user +/// has open sends it as `resource`, with the text already in the frame, and +/// through 0.39.0 io-cli skipped it silently — so the model was asked about a file +/// it had not been given and the operator had no way to see that the attachment +/// had gone nowhere. A `resource_link` carries no text, only a locator, and is +/// folded as the mention it is: naming a path the agent may read for itself is +/// what the block means. +/// +/// **io-cli-side entirely.** Nothing about this needs io-harness: the text is in +/// the frame and the prompt is a string. +/// +/// An `image` block is not text and is not folded here — see [`prompt_images`]. +/// Anything else is skipped rather than rendered as a placeholder the model would +/// try to read. pub fn prompt_text(params: &Value) -> Option { let blocks = params.get("prompt")?.as_array()?; - let text: Vec<&str> = blocks - .iter() - .filter(|block| block.get("type").and_then(Value::as_str) == Some("text")) - .filter_map(|block| block.get("text").and_then(Value::as_str)) - .collect(); - if text.is_empty() { + let mut parts: Vec = Vec::new(); + for block in blocks { + match block.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + parts.push(text.to_string()); + } + } + // `resource` nests the content one level down, and the specification + // allows either a text resource or a blob. Only the text one has + // anything a prompt can carry; a blob is skipped for the same reason + // an unknown block is. + Some("resource") => { + let resource = block.get("resource"); + let text = resource.and_then(|r| r.get("text")).and_then(Value::as_str); + let uri = resource.and_then(|r| r.get("uri")).and_then(Value::as_str); + if let Some(text) = text { + parts.push(match uri { + Some(uri) => format!("{uri}:\n{text}"), + None => text.to_string(), + }); + } + } + // A link is a locator and nothing else. Folded as a mention rather + // than read here: reading it would be a file access outside the + // session's own policy, made by the adapter instead of by the agent + // through the tool the policy governs. + Some("resource_link") => { + if let Some(uri) = block.get("uri").and_then(Value::as_str) { + parts.push(match block.get("name").and_then(Value::as_str) { + Some(name) => format!("{name} ({uri})"), + None => uri.to_string(), + }); + } + } + _ => {} + } + } + if parts.is_empty() { return None; } - Some(text.join("\n")) + Some(parts.join("\n")) +} + +/// The images of a `session/prompt`, and the ones that could not be taken. +/// +/// **Through `Media::attach`, which is the same door `/attach` uses** — so an ACP +/// image gets io-harness's own media-type table, its pixel-bomb guard, its +/// per-image byte bound and its conversion of the five formats a provider will not +/// take. Constructing a `Media` out of the frame's two fields would have been +/// shorter and would have skipped every one of those. +/// +/// A block that cannot be taken comes back as a sentence rather than failing the +/// prompt: the rest of what the client sent is still a prompt, and an editor that +/// attached one unreadable screenshot should not have its question thrown away. +pub fn prompt_images(params: &Value) -> (Vec, Vec) { + let (mut images, mut refused) = (Vec::new(), Vec::new()); + let Some(blocks) = params.get("prompt").and_then(Value::as_array) else { + return (images, refused); + }; + for block in blocks { + if block.get("type").and_then(Value::as_str) != Some("image") { + continue; + } + let media_type = block + .get("mimeType") + .and_then(Value::as_str) + .unwrap_or_default(); + let Some(data) = block.get("data").and_then(Value::as_str) else { + refused.push("an image block carried no `data`".to_string()); + continue; + }; + let Some(bytes) = from_base64(data) else { + refused.push(format!( + "an image block's `data` is not standard base64, so its {media_type} \ + could not be read" + )); + continue; + }; + match io_harness::Media::attach(media_type, &bytes) { + Ok(media) => images.push(media), + Err(error) => refused.push(error.to_string()), + } + } + (images, refused) +} + +/// Standard base64, no line breaks, padding optional. +/// +/// **Fifteen lines rather than a crate, and that is the argument for it.** The +/// dependency set is ten names and one of this release's own criteria is that it +/// stays ten; a decoder over a 64-entry alphabet is table lookup and shifting, and +/// what it buys is that the bytes go through `Media::attach` — io-harness's real +/// door, with its bounds and its conversions — instead of being poured into a +/// `Media`'s fields with no check at all. +/// +/// Rejects anything outside the alphabet, `=` included except as trailing +/// padding, so a `data:` URL or a whitespace-wrapped body is refused by name +/// rather than decoded into rubbish. +fn from_base64(text: &str) -> Option> { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let body = text.trim_end_matches('='); + if body.len() % 4 == 1 { + return None; + } + let mut out = Vec::with_capacity(body.len() / 4 * 3); + let (mut buffer, mut bits) = (0u32, 0u32); + for byte in body.bytes() { + let value = ALPHABET.iter().position(|c| *c == byte)? as u32; + buffer = (buffer << 6) | value; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push(((buffer >> bits) & 0xff) as u8); + } + } + Some(out) } /// The approver for an ACP turn. diff --git a/src/configure.rs b/src/configure.rs index 88d1809..1abc9ad 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -1101,6 +1101,40 @@ pub fn widens_workspace(key: &str, value: &str) -> bool { Config::from_toml(&document).is_err_and(|refusal| refusal.to_string().contains(WIDENS)) } +/// The scopes a key may actually be written to with this value, each with its +/// file. +/// +/// **The two `/config` doors disagreed, and this is what makes them agree** +/// (0.40.0). The value arm has checked [`widens_workspace`] since 0.28.0 and +/// refuses before it writes; the scope arm offered all three files and let +/// `write`'s round trip refuse two of them afterwards. So an operator choosing +/// `sandbox.mode = "full-access"` was shown `io.toml` and `io.local.toml` as +/// places to put it, picked one, and met a refusal the surface already knew was +/// coming — and the refusal is not of the key but of *the whole file*, which is +/// the worst moment to learn the rule. +/// +/// The predicate is a pure function of the pair and both are in hand when the +/// picker is built, so a scope that will be refused is not offered at all. +/// +/// **`configure::write`'s round trip stays, and it is not redundant.** It is what +/// restores "absent" rather than "empty" for every refusal this predicate does not +/// model — the refused *sections*, an absolute `run.skills`, anything io-harness +/// tightens next. A predicate that hid the backstop would be a copy of the rules +/// again, which is exactly what [`widens_workspace`] was rewritten to stop being. +#[must_use] +pub fn writable_scopes( + root: &std::path::Path, + key: &str, + value: &str, +) -> Vec<(Scope, std::path::PathBuf)> { + let widening = widens_workspace(key, value); + [Scope::User, Scope::Project, Scope::Local] + .into_iter() + .filter(|scope| !(widening && matches!(scope, Scope::Project | Scope::Local))) + .filter_map(|scope| scope_path(root, scope).map(|path| (scope, path))) + .collect() +} + /// 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/src/main.rs b/src/main.rs index 1e45090..e7f4775 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10771,14 +10771,10 @@ fn value_rows( } 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, - io_harness::config::Scope::Project, - io_harness::config::Scope::Local, - ] - .into_iter() - .filter_map(|scope| io_cli::configure::scope_path(root, scope).map(|p| (scope, p))) - .collect(); + // **Only the scopes the write will be taken in (0.40.0).** The decision is + // `configure::writable_scopes`, in the library, because nothing under `tests/` + // links this file — a filter written here would be one no gate could reach. + let paths = io_cli::configure::writable_scopes(root, &key, &value); let rows: Vec = paths .iter() diff --git a/tests/acp.rs b/tests/acp.rs index c84caff..7f1d095 100644 --- a/tests/acp.rs +++ b/tests/acp.rs @@ -159,17 +159,29 @@ fn f2_initialize_answers_protocol_version_one_and_needs_no_authentication() { "no authentication step is required of the client", ); - // Everything omitted is unsupported by the specification's own rule, so the - // three prompt capabilities being present and false is a statement rather - // than an oversight. + // Everything omitted is unsupported by the specification's own rule, so a + // prompt capability being present and false is a statement rather than an + // oversight — and one being true is a promise `tests/acp.rs` keeps this + // adapter to below. let prompt = &result["agentCapabilities"]["promptCapabilities"]; - for name in ["image", "audio", "embeddedContext"] { + for name in ["image", "embeddedContext"] { assert_eq!( prompt[name], - json!(false), - "`{name}` is not carried into a run yet and must not be promised", + json!(true), + "`{name}` reaches a run from 0.40.0 and the client has no other way to \ + learn it may send one", ); } + // **`audio` stays false, and io-harness is the reason.** `Media` has an image + // media type and nothing else, and the crate renders an audio file as a named + // non-attachment — so there is nothing one layer down to carry the bytes, + // whatever this adapter does. A capability declared here would be a promise + // this product cannot keep. + assert_eq!( + result["agentCapabilities"]["promptCapabilities"]["audio"], + json!(false), + "io-harness carries no audio, so declaring it would be a promise io cannot keep", + ); // **`true` since 0.39.0.** It was false because the correspondence between an // ACP session id and a stored conversation had to be designed rather than // guessed; the design turned out to be the one the id already carried, since @@ -1854,3 +1866,124 @@ async fn f11_a_response_naming_an_unknown_id_is_dropped() { "the correlator still settles its own ids" ); } + +/// **F7 — ACP carries an embedded resource and an image.** +/// +/// The text half is io-cli's alone: an editor that sends the file the user has +/// open sends it as `resource`, with the text already in the frame, and through +/// 0.39.0 the block was skipped silently — so the model was asked about a file it +/// had never been given and nothing said so. +/// +/// Sabotage: drop the `resource` arm from `acp::prompt_text`. The first assertion +/// goes red and the plain-text one stays green, which is what separates "the fold +/// works" from "there was text in the frame anyway". +#[test] +fn f7_an_embedded_resource_reaches_the_prompt() { + let params = json!({ + "sessionId": "io-1", + "prompt": [ + { "type": "text", "text": "why does this fail?" }, + { + "type": "resource", + "resource": { + "uri": "file:///w/src/lib.rs", + "mimeType": "text/x-rust", + "text": "fn main() { panic!() }", + }, + }, + { "type": "resource_link", "uri": "file:///w/README.md", "name": "README.md" }, + ], + }); + + let folded = acp::prompt_text(¶ms).expect("a prompt with text in it"); + assert!( + folded.contains("why does this fail?"), + "the typed question is still the prompt: {folded:?}", + ); + assert!( + folded.contains("fn main() { panic!() }"), + "an embedded resource's text has to reach the model, or the client attached \ + a file to nothing: {folded:?}", + ); + assert!( + folded.contains("file:///w/src/lib.rs"), + "the resource is named as well as quoted, so the model can tell which file \ + it is reading: {folded:?}", + ); + // A link carries no text, only a locator. It is folded as the mention it is + // and never read here — reading it would be a file access outside the + // session's own policy, made by the adapter instead of by the agent. + assert!( + folded.contains("README.md (file:///w/README.md)"), + "a resource_link is folded as a mention: {folded:?}", + ); + + // A prompt of nothing but blocks this adapter does not fold is still not a + // prompt, which is what the caller's `InvalidParams` rests on. + assert!(acp::prompt_text(&json!({ "prompt": [] })).is_none()); + assert!( + acp::prompt_text(&json!({ "prompt": [{ "type": "audio", "data": "AA==" }] })).is_none(), + ); +} + +/// **F7 — an image block reaches the contract as a `Media` with its type and +/// bytes.** +/// +/// Through `Media::attach`, which is the same door `/attach` uses — so an ACP +/// image gets io-harness's media-type table, its pixel-bomb guard and its byte +/// bound. Constructing a `Media` out of the frame's two fields would have been +/// shorter and skipped all three. +/// +/// Sabotage: return an empty vector from `acp::prompt_images`. The first +/// assertion goes red. +#[test] +fn f7_an_image_block_becomes_media_and_a_bad_one_is_said_rather_than_swallowed() { + // A 1×1 PNG, base64 as a client would send it. + const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + + let (images, refused) = acp::prompt_images(&json!({ + "prompt": [ + { "type": "text", "text": "what is in this?" }, + { "type": "image", "mimeType": "image/png", "data": PNG }, + ], + })); + assert_eq!(images.len(), 1, "one image block is one attachment"); + assert_eq!( + images[0].media_type, "image/png", + "the media type reaches the provider boundary as the client declared it", + ); + assert_eq!( + images[0].base64, PNG, + "a png passes through byte-identically rather than being re-encoded", + ); + assert!(refused.is_empty(), "nothing was refused: {refused:?}"); + + // What cannot be taken is said, and the rest of the prompt survives it. An + // editor that attached one unreadable screenshot must not lose its question. + let (images, refused) = acp::prompt_images(&json!({ + "prompt": [ + { "type": "image", "mimeType": "image/svg+xml", "data": "PHN2Zy8+" }, + { "type": "image", "mimeType": "image/png", "data": "not base64!" }, + { "type": "image", "mimeType": "image/png" }, + ], + })); + assert!(images.is_empty()); + assert_eq!( + refused.len(), + 3, + "each block that could not be taken says so: {refused:?}", + ); + assert!( + refused[0].contains("SVG"), + "the refusal is io-harness's own sentence, which names the format and the \ + conversion that fixes it: {refused:?}", + ); + assert!( + refused[1].contains("base64"), + "a body that is not base64 is named as that rather than as a bad image: {refused:?}", + ); + assert!( + refused[2].contains("`data`"), + "a block with no data at all says which field is missing: {refused:?}", + ); +} diff --git a/tests/configure.rs b/tests/configure.rs index 07a68cd..2269731 100644 --- a/tests/configure.rs +++ b/tests/configure.rs @@ -702,6 +702,107 @@ fn f1_a_typed_key_still_refuses_a_value_and_an_absent_key_still_loads() { ); } +/// **F6 — the scope picker offers no scope the write will be refused for.** +/// +/// The two `/config` doors disagreed: the value arm has checked +/// `widens_workspace` since 0.28.0, and the scope arm offered all three files and +/// let the round trip refuse two of them afterwards — so an operator was shown +/// `io.toml` and `io.local.toml`, picked one, and met a refusal the surface +/// already knew was coming. And the refusal is not of the key but of the whole +/// file. +/// +/// Sabotage: drop the filter from `configure::writable_scopes`. The first arm goes +/// red naming the scopes it should not have offered. +#[test] +fn f6_the_scope_picker_offers_no_scope_the_write_will_be_refused_for() { + let s = scopes("", "", ""); + let root = s.root.path(); + + // A widening pair: the user scope and nothing else. + for (key, value) in [ + ("sandbox.mode", "full-access"), + ("sandbox.allow_network", "true"), + ("policy.defaults.write", "allow"), + ("sandbox.limits.max_processes", "0"), + ] { + let offered: Vec = configure::writable_scopes(root, key, value) + .into_iter() + .map(|(scope, _)| scope) + .collect(); + assert_eq!( + offered, + vec![Scope::User], + "`{key} = {value}` widens the boundary, so a file inside the workspace \ + may not carry it — and offering one is offering a row whose write \ + io-harness refuses, taking the whole file with it", + ); + } + + // A narrowing value of the same key, and an ordinary one: all three. + for (key, value) in [("sandbox.mode", "read-only"), ("run.max_steps", "500")] { + let offered: Vec = configure::writable_scopes(root, key, value) + .into_iter() + .map(|(scope, _)| scope) + .collect(); + assert_eq!( + offered, + vec![Scope::User, Scope::Project, Scope::Local], + "`{key} = {value}` is legal in every scope and the picker must offer \ + every scope; narrowing in a workspace file is what the scope is for", + ); + } +} + +/// **F6's other half — the round trip is still the backstop.** +/// +/// The one to keep. `writable_scopes` models widening and nothing else, so every +/// refusal it does not model — a refused section, an absolute `run.skills`, +/// whatever io-harness tightens next — still has to be caught by `write` reading +/// the file back and restoring what was there. A criterion that asserted only the +/// predicate would pass just as well over a `write` that stopped checking. +/// +/// **`[browser]` names a program to execute**, so io-harness refuses the section +/// from any file inside the workspace — and it is not a widening *value*, so +/// `widens_workspace` answers false for it and the picker offers all three scopes. +/// That is the hole, and this is what covers it. +#[test] +fn f6_a_refusal_the_predicate_does_not_model_still_restores_absence() { + let s = scopes("", "", ""); + let root = s.root.path(); + + // The predicate does not see it. + assert!( + !configure::widens_workspace("browser.binary", "/usr/bin/chromium"), + "a refused section is not a widening value, which is exactly why the round \ + trip cannot be dropped", + ); + + let _guard = env_lock(); + std::env::set_var("IO_CONFIG", &s.user); + let refused = configure::write( + root, + Scope::Project, + &[io_cli::edit::Edit::set( + "browser.binary", + "\"/usr/bin/chromium\"", + )], + ); + std::env::remove_var("IO_CONFIG"); + + assert!( + refused.is_err(), + "io-harness refuses `[browser]` from a file inside the workspace and the \ + write reported success", + ); + // Absent, not empty. The file did not exist before the call, so what it goes + // back to is "not there" — an empty `io.toml` left behind is a project file + // this operator never wrote. + assert!( + !root.join("io.toml").exists(), + "a refused write left a project file behind that the operator never wrote", + ); +} + // --- F3: the write lands in the scope that was picked, and takes effect ------- #[test] From f9e7e1c00bd14fa60bcabebafd809ef12f6bfa52 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Mon, 7 Sep 2026 09:00:22 +0530 Subject: [PATCH 6/9] docs: the 0.40.0 changelog, the feature gate, and the tap step that was skipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Cargo.toml` said the no-new-crate claim for `otel`, `mcp-server` and `codeact` was "verified against `cargo tree --depth 1` … see `tests/dependencies.rs`". It was not: that file parses `Cargo.toml` and `Cargo.lock` and never runs cargo, and `--depth 1` prints io-cli's own direct dependencies, which is exactly where a transitive crate would not appear. The claim is true and checkable at its source — a feature that turns nothing on has an empty list, and one that enables a feature of a crate already present names no `dep:`. So the test reads the `[features]` table of the io-harness version `Cargo.lock` pins and asserts that, with a control asserting the table parsed at all: a path miss is how a manifest gate goes vacuous rather than red. The sentence in `Cargo.toml` was amended to say what is actually asserted. `docs/RELEASE_PROCESS.md`'s tap step gains the reason it is not an errand. 0.39.0 shipped on 2026-09-06 with `Formula/io.rb` and `bucket/io.json` still naming 0.38.2, so for anyone installing through brew or scoop the release notes described a version they could not get. `docs/CONTRACT.md` gains the forty-step gated cap, which decides whether an unattended run finishes and appeared in no shipped document at all. --- CHANGELOG.md | 89 +++++++++++++++++++++++++++++++++++++- Cargo.toml | 15 +++++-- docs/CONTRACT.md | 7 +++ docs/RELEASE_PROCESS.md | 9 ++++ tests/dependencies.rs | 94 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 209 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 171da9e..53f2fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,92 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.40.0] - 2026-09-07 + +Every key io documents can be set from either door, and nothing decides on your +behalf. + +### Upgrading + +Two shipped interactions change. Neither is a correction you will not notice. + +- **An approval now needs `Enter`.** `y`, `a` and `n` move the highlight to that + answer; nothing resolves until you press `Enter`, which is the same two-step the + arrow keys have always used. It costs one keystroke and it buys the thing that + was wrong: a letter inside a sentence can no longer decide a write. Typing "no, + not that one" at an approval used to deny with the `n` in "no" and leave the rest + of the sentence in the composer, which was then sent as a prompt. + +- **A gated run that wrote `max_steps = 1000` now takes a thousand steps.** It took + forty. `[run] max_steps = 1000` is io-cli's own floor written down, so it was + indistinguishable from having written nothing and the gated cap applied anyway. + If you have a CI job that wrote a thousand and relied on the forty-step cap to + bound it, that job now runs to a thousand. + +### Fixed + +- **Thirteen `[app.io-cli]` keys are settable from the shell at last.** + `io config set` wrote a string wherever a number or a flag belonged, for every + key `kind_of` did not name — and the cost is not the key. io-harness reads + `[app.io-cli]` as one opaque value, so one bad value fails the **whole section** + and the session silently reverts theme, keybindings, containment, gates and + routing to defaults behind a single warning line. The thirteen are + `reference_catalogue`, `prices.models`, the five `[app.io-cli.browser]` keys and + the six `[app.io-cli.containment]` ceilings. + + `reference_catalogue` is the sharpest of them: 0.39.0 shipped it as the way to + turn its new network call off, and left it unsettable. + + 0.38.1 fixed this class of defect for `max_total_agents` and the fix did not + generalise, because nothing in the suite walked from the settings struct to the + catalogue — a list cannot notice what is missing from it. That walk exists now, + and it found a fourteenth key while it was being written. + +- **`max_total_duration` is settable at all.** It deserializes as a `Duration`, so + what the file has to carry is `{ secs = N, nanos = 0 }` and no scalar you type is + a value for it. `io config set app.io-cli.containment.max_total_duration 3600` + takes the seconds and writes the table. + +- **`/undo` forgets the thought as well as the files.** The whole-turn undo cleared + the status, the fleet and the seen set and never touched the transcript's own + memory, so `/expand` still printed the undone turn's reasoning — the one thing + you undo a turn to be rid of, kept by the act meant to remove it. + +- **The `/config` scope picker offers only the scopes a key can be written to.** A + widening value is refused from any file inside the workspace, so offering + `io.toml` and `io.local.toml` was offering two rows whose write io-harness + refuses — and it refuses the whole file, not the key. + +### Added + +- **ACP carries an image and an embedded resource.** `image` and `embeddedContext` + are declared, so a conforming editor will offer both. An image reaches the model + as an attachment on that one turn, through the same door `/attach` uses; a + `resource` block is folded into the prompt with its uri, which is how an editor + hands over the file you have open. A block that cannot be taken is reported back + as a message rather than dropped. `audio` stays false and will until io-harness + carries audio. + +### Changed + +- **The pin moves to io-harness 0.83.0.** `sandbox.allow_network = true` reaches + the macOS backend on a proxied run — which is every real run — so a widened + sandbox widens, and `git_log` on a repository with no commits answers in its own + words instead of with the error git also returns for "not a git repository". + Both were reported from this product and neither was this product's to fix. + +- **Four sentences that were false are corrected, and each has a gate.** The commit + identity a repository with none of its own will carry is named + (`io-harness agent `) rather than left to be discovered + in `git log`; the forty-step gated cap is documented instead of contradicted; the + `--json` stream says that `reasoning` arrives after the step whose tokens it + explains; and `Cargo.toml` says what its dependency gate actually asserts. + +- **`io.local.toml` has been widening-checked since io-harness 0.74.0, and three + pages still offered it as the place to put a widening value** — including + `skills/io-permissions.md`, which is what an agent reads. The refused list is + thirteen (key, value) pairs across twelve keys, not the five those pages named. + ## [0.39.0] - 2026-09-06 No keystroke loses your work, a running turn shows more of what it is doing, and @@ -3749,7 +3835,8 @@ client, tool, sandbox, policy engine or session store of its own. - There is no crates.io publish and `cargo install` is not an install path. - No test in this release asserts on wall-clock time. -[Unreleased]: https://github.com/initorigin/io-cli/compare/v0.38.0...HEAD +[Unreleased]: https://github.com/initorigin/io-cli/compare/v0.40.0...HEAD +[0.40.0]: https://github.com/initorigin/io-cli/compare/v0.39.0...v0.40.0 [0.39.0]: https://github.com/initorigin/io-cli/compare/v0.38.2...v0.39.0 [0.38.2]: https://github.com/initorigin/io-cli/compare/v0.38.1...v0.38.2 [0.38.1]: https://github.com/initorigin/io-cli/compare/v0.38.0...v0.38.1 diff --git a/Cargo.toml b/Cargo.toml index ada3a1f..d648596 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,10 +69,17 @@ path = "src/lib.rs" # ["tokio/io-std"]` is two more types inside a name already here, the JSON-RPC # framing hand-written precisely to avoid the nine packages `rmcp`'s server half # pulls in; and `codeact = []` turns nothing on at all, because its interpreter -# is a host binary discovered the way a browser is and never downloaded. The -# claim is verified against `cargo tree --depth 1` with all six features on -# rather than believed from the harness's manifest — see `tests/dependencies.rs`, -# which asserts the ten names in both directions. +# is a host binary discovered the way a browser is and never downloaded. +# +# **That claim is checked at its source, and the sentence that stood here said +# something else (0.40.0).** It said the claim was verified against `cargo tree +# --depth 1` by `tests/dependencies.rs` — which parses this file and `Cargo.lock` +# and never runs cargo, and `--depth 1` prints io-cli's own DIRECT dependencies, +# which is exactly where a transitive crate would not appear. What is asserted is +# what can be: `f8_the_pinned_harness_features_this_crate_turns_on_name_no_dependency` +# reads the `[features]` table of the io-harness version `Cargo.lock` pins and +# holds those three to naming no `dep:`. The ten-name allowlist is a separate gate +# in the same file, asserted in both directions. io-harness = { version = "0.83", features = [ "media", "browser", diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index d09728c..6993591 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -189,6 +189,13 @@ deleted that was not already copied. This does nothing at all when `$IO_CONFIG` ## Limits that hold today +**A gated headless run with no budget stops at forty steps and exits `3`.** A `[app.io-cli.gates]` +criterion takes away every bound io-cli's own floor of a thousand is safe because of — io-harness +hands a failed criterion back as a reason to keep going, a run with no budget is measured against +nothing, and headless has no operator — so a run that set no `max_steps`, `max_duration_secs` or +`max_tokens` is bounded at forty. Anything you wrote beats it, **including `max_steps = 1000`**, +which through 0.39.0 was indistinguishable from having written nothing. + **A marketplace, and an entry inside one, resolve against `github.com` and no other host.** A marketplace is named `/`; an index entry naming a repository elsewhere has its url re-derived to that shape, and a url that is not two ordinary path segments on that host is refused diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 474adce..19ca816 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -63,6 +63,15 @@ irreversible in front of something reviewable. `brew` reads a tap's **default branch**, which is `develop`, so the tap is live when that pull request merges and needs no `main` merge of its own. + **It is a step of the release, not an errand after it, and 0.39.0 is why this paragraph + exists.** That release shipped on 2026-09-06 and both files still named 0.38.2 — so for + everyone installing through `brew` or `scoop`, the release notes described a version they could + not get, and `release.yml`'s note that the tap follows "shortly after" was untrue for anyone + reading it. The release is not finished until this pull request is merged and a `brew install` + resolves to the version the notes name. `tests/packaging.rs` asserts the two files agree with + each other and name a released version; it cannot assert they name the *newest* one, because + the tree that would have to know is the tree that has not shipped yet. + ## What the workflow builds `release.yml` builds four targets — `aarch64-apple-darwin`, `x86_64-apple-darwin`, diff --git a/tests/dependencies.rs b/tests/dependencies.rs index 9edc7b3..7c21cbf 100644 --- a/tests/dependencies.rs +++ b/tests/dependencies.rs @@ -1372,6 +1372,100 @@ fn n3_no_second_async_executor_is_in_the_tree() { } } +/// **F8 — the pinned harness's own features name no dependency.** +/// +/// `Cargo.toml` claims that `otel`, `mcp-server` and `codeact` add no crate, and +/// through 0.39.0 it said that claim was "verified against `cargo tree --depth 1` +/// … see `tests/dependencies.rs`". It was not. This file parses `Cargo.toml` and +/// `Cargo.lock`; `cargo tree --depth 1` prints io-cli's own *direct* dependencies, +/// which is where a transitive crate would not appear at all. The `ALLOWED` gate +/// is a real gate and it does not check that. +/// +/// The claim is true and it is checkable at its source: a feature that turns +/// nothing on has an empty list, and one that enables a feature of a crate already +/// present names no `dep:`. So this reads the manifest of the version `Cargo.lock` +/// pins and asserts exactly that, and the sentence in `Cargo.toml` was amended to +/// say what is actually asserted. +/// +/// Sabotage: point `MANIFEST_FEATURES` at a name no `[features]` table has. The +/// control below goes red rather than the whole test passing over a file it never +/// found — a path miss is the way a manifest gate goes vacuous. +#[test] +fn f8_the_pinned_harness_features_this_crate_turns_on_name_no_dependency() { + let manifest = harness_manifest(); + let features = manifest + .split_once("\n[features]") + .map(|(_, rest)| rest.split("\n[").next().unwrap_or(rest)) + .expect("the pinned io-harness manifest declares a [features] table"); + + // The control, first. A `[features]` table this parser did not find would + // satisfy every absence below by holding nothing at all. + assert!( + features.contains("media = ["), + "the [features] table was not read out of the pinned manifest, so the \ + assertions below are about an empty string: {features:.200?}", + ); + + for feature in ["otel", "codeact", "mcp-server"] { + let line = features + .lines() + .find(|line| line.trim_start().starts_with(&format!("{feature} = ["))) + .unwrap_or_else(|| { + panic!( + "the pinned io-harness declares no `{feature}` feature; Cargo.toml turns it on" + ) + }); + assert!( + !line.contains("dep:"), + "`{feature}` names a dependency: {line}. Cargo.toml says these three add \ + no crate, and a feature that names a `dep:` is one that does — so either \ + the claim in the manifest is now false or the feature should come off.", + ); + } +} + +/// The pinned io-harness's own `Cargo.toml`. +/// +/// The version comes from `Cargo.lock` rather than from `Cargo.toml`, which +/// carries a caret requirement and not a version: `"0.83"` matches 0.83.4 too, and +/// the manifest this asserts on has to be the one that is actually built. +fn harness_manifest() -> String { + let lock = std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.lock")) + .expect("Cargo.lock exists; this crate is a binary and commits its lockfile"); + let version = lock + .split("name = \"io-harness\"") + .nth(1) + .and_then(|rest| rest.split_once("version = \"")) + .and_then(|(_, rest)| rest.split_once('"')) + .map(|(version, _)| version.to_string()) + .expect("io-harness is in the lockfile"); + + let home = std::env::var_os("CARGO_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo"))) + .expect("a cargo home"); + let registries = home.join("registry").join("src"); + let entries = std::fs::read_dir(®istries) + .unwrap_or_else(|error| panic!("{} is readable: {error}", registries.display())); + for entry in entries.flatten() { + let candidate = entry + .path() + .join(format!("io-harness-{version}")) + .join("Cargo.toml"); + if candidate.is_file() { + // Normalised for the same reason every other reader here normalises: + // git checks the registry out with CRLF on Windows, where CI runs. + return std::fs::read_to_string(&candidate) + .expect("the pinned manifest is readable") + .replace("\r\n", "\n"); + } + } + panic!( + "io-harness {version} is not unpacked under {}", + registries.display() + ); +} + /// **N3's other half — there is exactly one place a future is driven, and it is /// the entry point.** /// From 47d96174860873f9de9dc1d92c499cef1cd9100f Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Mon, 7 Sep 2026 09:09:03 +0530 Subject: [PATCH 7/9] fix(config): a list key takes its words after `--`, so both of them are settable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driving the argv door found the thirteenth key. The two list keys are a command line and a browser's arguments, and the value of either routinely begins with a dash — so `io config set app.io-cli.gates.command cargo test --all-features` was refused by io's own flag parser, naming `--all-features` as a flag it does not take. That invocation is the worked example `docs/config.example.toml` has shipped since 0.24.0 and it had never run; `--disable-gpu` is the same word one release later, which is how this release met it. Nothing below the door would have found it. `tests/configure.rs` drives `configure::source_for`, one layer past the parse that refuses, so the unit gate for `browser.args` was green while the command an operator types could not run. Fifth consecutive release in which driving the real command line caught something. The mechanism is the one this parser already has and already recommends: its refusal for a single-dash token says outright that a flag meant for the program belongs after `--`, and `scan` stops dead at the first `--` so everything past it is copied through as text. `config set` accepts that section for a list key and keeps refusing it for every other key, where the words are one value and a separator before them is a mistake worth naming. The two documented invocations are corrected to carry the separator. --- docs/config.example.toml | 15 +++++++-- docs/guide/headless.md | 2 +- src/manage.rs | 31 +++++++++++++++++-- tests/manage.rs | 67 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 6 deletions(-) diff --git a/docs/config.example.toml b/docs/config.example.toml index 72117ea..c302743 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -818,7 +818,9 @@ fleet = "ctrl+f" # read which binary will be picked rather than running it to find # out. # args extra arguments, a list. Written from the shell as the remaining -# words: `io config set app.io-cli.browser.args --disable-gpu`. +# words after `--`, because they begin with a dash and io's own +# flags are spelled the same way: +# `io config set app.io-cli.browser.args -- --disable-gpu`. # headless true or false. # width/height the viewport, in whole pixels. # timeout_secs a whole number of seconds for one navigation. @@ -897,8 +899,15 @@ fleet = "ctrl+f" # 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. +# with free text. `io config set app.io-cli.gates.command -- cargo test --all-features` +# takes the words after the `--` and writes them as the array. +# +# THE `--` IS LOAD-BEARING AND THIS LINE DID NOT CARRY IT UNTIL 0.40.0. A command +# line routinely contains a word beginning with a dash, io's own flags are spelled +# the same way, and the parser refused `--all-features` as a flag it does not take +# — so the worked example this file has shipped since 0.24.0 had never run. `--` is +# the same separator `/mcp add -- ` uses, and everything after it +# is copied through as text. # # `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". diff --git a/docs/guide/headless.md b/docs/guide/headless.md index 68ab34a..ebcb2e5 100644 --- a/docs/guide/headless.md +++ b/docs/guide/headless.md @@ -302,7 +302,7 @@ io plugin marketplace remove zeroonething/ultraship io config get run.max_steps io config set run.max_steps 40 -io config set app.io-cli.gates.command cargo test --all-features +io config set app.io-cli.gates.command -- cargo test --all-features io config unset run.max_steps io config list ``` diff --git a/src/manage.rs b/src/manage.rs index 2b29b60..628e3e8 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -1593,7 +1593,6 @@ fn mcp_edit(args: &Args) -> Result { /// `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()?; @@ -1604,7 +1603,35 @@ fn config_set(args: &Args) -> Result { .to_string(), ); }; - let value = config_value(&key, &args.positional[1..])?; + + // **A list key takes its words after `--`, and until 0.40.0 no list key could + // be set from a shell at all.** The two list keys are a command line and a + // browser's arguments, and the value of either routinely begins with a dash — + // so `io config set app.io-cli.gates.command cargo test --all` was refused by + // `only` above, naming `--all` as a flag io does not take. That invocation is + // the worked example `docs/config.example.toml` has shipped since 0.24.0 and + // it had never run. `--disable-gpu` is the same word one release later. + // + // The mechanism is the one this parser already has and already recommends: the + // refusal for a single-dash token says outright that "a flag meant for the + // server itself belongs after `--`", and `scan` stops dead at the first `--` + // so everything past it is copied through as text. What this adds is that + // `config set` accepts that section instead of refusing it — for a list key + // only, because for every other key the words are one value and a `--` before + // them is a mistake worth naming. + let listed = matches!( + crate::configure::kind_of(&key), + Some(crate::configure::Kind::List) + ); + let mut words = args.positional[1..].to_vec(); + match (&args.opaque, listed) { + (Some(rest), true) => words.extend(rest.iter().cloned()), + (Some(_), false) => { + args.no_command("config set")?; + } + (None, _) => {} + } + let value = config_value(&key, &words)?; // Reported here rather than discovered by the round trip, because the round // trip's refusal takes the WHOLE FILE: `refuse_widening` runs before diff --git a/tests/manage.rs b/tests/manage.rs index 5389c53..2c15fde 100644 --- a/tests/manage.rs +++ b/tests/manage.rs @@ -1465,3 +1465,70 @@ fn f2_the_argv_door_writes_the_shape_that_was_typed() { ); } } + +/// **F1 — a list key is settable from a shell, and until 0.40.0 neither of the two +/// was.** +/// +/// The two list keys are a command line and a browser's arguments, and the value +/// of either routinely begins with a dash — so `io config set +/// app.io-cli.gates.command cargo test --all-features` was refused by io's own +/// flag parser, naming `--all-features` as a flag it does not take. **That +/// invocation is the worked example `docs/config.example.toml` has shipped since +/// 0.24.0, and it had never run.** `--disable-gpu` is the same word one release +/// later, which is how 0.40.0 met it. +/// +/// **This is the argv door and nothing below it would have found this.** +/// `tests/configure.rs` drives `configure::source_for`, which is one layer past +/// the parse that refuses — so the unit gate for `browser.args` was green while +/// the command an operator types could not run. Fifth consecutive release in which +/// driving the real command line caught something. +/// +/// Sabotage: restore `args.no_command("config set")?` at the top of `config_set`. +/// Both writes below are refused and the reads print no row. +#[test] +fn f1_a_list_key_takes_its_words_after_the_separator() { + let fixture = tempfile::tempdir().expect("a temporary home"); + let home = fixture.path(); + + for (key, words, read_back) in [ + ( + "app.io-cli.gates.command", + ["--", "cargo", "test", "--all-features"], + r#"["cargo", "test", "--all-features"]"#, + ), + ( + "app.io-cli.browser.args", + ["--", "--disable-gpu", "--no-sandbox", "--headless"], + r#"["--disable-gpu", "--no-sandbox", "--headless"]"#, + ), + ] { + let mut argv = vec!["config", "set", "--scope", "user", key]; + argv.extend(words); + let (_, stderr) = io_at(home, &argv); + let (out, _) = io_at(home, &["config", "get", key]); + + let value = out + .lines() + .find(|line| line.starts_with(key)) + .and_then(|line| line.split('\t').nth(1)) + .map(str::trim) + .unwrap_or_else(|| { + panic!("`config get {key}` printed no row after the write said:\n{stderr}\n{out}") + }); + assert_eq!( + value, read_back, + "`io config set {key} -- …` should write the words after the separator \ + as the array io-harness reads back. The write said:\n{stderr}", + ); + } + + // And a `--` on a key that is not a list is still the mistake it always was: + // for every other key the words are one value, and a separator before them + // means the operator thought they were writing a command line. + let (_, stderr) = io_at(home, &["config", "set", "run.max_steps", "--", "30"]); + assert!( + stderr.contains("takes no command"), + "a `--` on a scalar key has to be named rather than folded into the value: \ + {stderr}", + ); +} From 2d832654c2e08bf5a516b5340d78e09b821d51a4 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Mon, 7 Sep 2026 09:49:14 +0530 Subject: [PATCH 8/9] fix: the adversarial review's findings, and a live gate that read a refusal as a success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourteen findings against a green suite. Four were behaviour. **`configure::EXCLUDED` enforced nothing.** It was added as the other half of the settings-struct walk, and both `/config` doors reached the fall-through regardless: `kind_of` answers `None` for `app.io-cli.browser.binary`, so it fell to the arm that quotes any unrecognised `app.io-cli.*` word and produced a valid string for an `Option` field. `io config set app.io-cli.browser.binary /usr/bin/x --scope project` succeeded and took effect — the exact act the entry's own reason says must not be offered, in the one section io-harness reads opaquely and never widening-checks. The reason strings had no reader at all; `source_for` consults them now, so they are what the operator is told and what keeps them true. **`browser.args` was offered at project scope on the argument used to exclude `binary`.** It is the argument vector of that same program, which is where a `--proxy-server=https://user:pass@host` or a `--load-extension` goes — io-harness redacts it from its own `Debug` for exactly that shape. Committing the arguments is committing the binary one word further along. `configure::USER_SCOPE_ONLY` holds it to the user scope at both doors, with the reason attached. **ACP declared `image: true` and never asked the provider.** io-harness refuses the whole turn at request time for a text-only model, and this door reports that on stderr — so the client saw a turn that ended having said nothing, which is the operator's question thrown away. `prompt_images` takes `accepts_images` now, which is the guard `/attach` has always had. **An image-only prompt lost its refusals.** The goal was computed first, so `InvalidParams` returned on the `?` before any refusal sentence was sent — for exactly the prompt `image: true` invites. Images are decoded and spoken about first; the refusal for a prompt with no words in it says what a prompt must carry. An embedded blob is named too, rather than skipped in silence. **A live gate read the mask working as the mask failing.** `live_f6` matched `Step { tool_call }` on the `write_file:` prefix — and a REFUSED call commits a step with that same prefix and `decision: "write_file refused: withheld from this turn"`. The 0.40.0 live run is the first in which a model actually attempted the withheld call, so the arm had never fired; the decision is what separates the two outcomes its own comment describes. Six more shipped pages sent an operator to `io.local.toml` for a section io-harness refuses from any file inside the workspace — `[[hook]]`, `[[plugin]]`, `${cmd:}` — including two agent-read skills. The N2 sweep that should have caught them greps five literal phrases, so it asserted its fixture rather than its property; it is paragraph-scoped over the refused sections now. Its first draft was vacuous too, excusing any paragraph containing "may not" — which is how the stale hooks page opens — and that was found by restoring the page from git and watching the gate stay green. The rest: an ordering comment in `undo_whole_turn` that invented a reason, two "exactly one list key" claims false in the release that adds the second, an F1 completeness loop that checked eleven of thirteen while claiming all, a `spell_value` arm reasoning about a call that cannot happen, a sabotage control named after a test that does not exist, an example paragraph that reverted to the corrected-away wording, a refusal ending in MCP wording on a `config set`, and an image refusal with a hole where the media type goes. --- docs/config.example.toml | 29 ++++++--- docs/guide/hooks.md | 6 +- docs/guide/plugins.md | 7 +- skills/io-mcp.md | 8 ++- skills/io-provider.md | 9 +-- src/acp.rs | 83 +++++++++++++++++++++--- src/configure.rs | 105 ++++++++++++++++++++++++------ src/main.rs | 11 ++-- src/manage.rs | 21 +++++- tests/acp.rs | 110 +++++++++++++++++++++++++++---- tests/configure.rs | 136 +++++++++++++++++++++++++++++++++++---- tests/docs.rs | 80 ++++++++++++++++++++++- tests/live.rs | 16 ++++- 13 files changed, 541 insertions(+), 80 deletions(-) diff --git a/docs/config.example.toml b/docs/config.example.toml index c302743..b77ada8 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -430,8 +430,10 @@ net = "deny" # skills, templates, agents and policy, and may NOT contribute hooks or MCP # servers, because both of those run a program on this machine. One that tries is # refused WHOLE: it contributes nothing at all, not the half that would have been -# safe. Name it from io.local.toml or from this user-scope file and the same -# directory loads completely. The rule is about which file, not about the bundle. +# safe. Name it from THIS user-scope file and the same directory loads completely; +# io.local.toml is refused for the same reason since io-harness 0.74.0, being a +# path inside the workspace the run's own agent can write to. The rule is about +# which file, not about the bundle. # # A BUNDLE'S POLICY MAY ONLY DENY. A `[policy] defaults` table in a manifest is # refused by name, and a single rule whose effect is not `deny` drops the bundle. @@ -490,8 +492,10 @@ net = "deny" # on this machine, and io.toml is the file a `git clone` delivers. There is no # Config to be had, so io genuinely cannot start against such a file; what 0.20.0 # changed is that it prints io-harness's own sentence, which names the key, the -# reason and the two files that may carry it, instead of a bare error string. -# Write the table here or in io.local.toml. +# reason and the file that may carry it, instead of a bare error string. +# Write the table HERE, in the user-scope file, and nowhere else: io.local.toml is +# refused too since io-harness 0.74.0, because it sits in a root the run's own +# agent can write to. # # [[hook]] # on = [] # every event @@ -809,10 +813,17 @@ fleet = "ctrl+f" # # `binary` is DELIBERATELY not offered at either `/config` door, and the reason is # not that it is hard to type. It names a program to execute; io-harness refuses a -# whole top-level [browser] section in a project file for exactly that reason, and -# it cannot apply that rule here because [app.io-cli] is the section it reads as an +# whole top-level [browser] section from ANY file inside the workspace — +# `io.local.toml` included since its 0.74.0 — for exactly that reason, and it +# cannot apply that rule here because [app.io-cli] is the section it reads as an # opaque value. Write it by hand, in the file you chose, as below. # +# `args` IS settable, and only into the user-scope file. It is the argument vector +# of that same program, which is where a `--proxy-server=https://user:pass@host` or +# a `--load-extension` goes — committing one is the same act as committing the +# binary, one word further along. `io config set --scope project` refuses it and +# `/config` does not offer the scope. +# # binary the browser executable. ABSENT resolves it from io-harness's own # ordered list of well-known names, which is documented — so you # read which binary will be picked rather than running it to find @@ -897,9 +908,9 @@ fleet = "ctrl+f" # 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` +# test"` — is a value io-harness cannot read back, which is why it is one of the +# two keys in io-cli's catalogue with a list of its own rather than being lumped +# in with free text. `[app.io-cli.browser] args` is the other, since 0.40.0. `io config set app.io-cli.gates.command -- cargo test --all-features` # takes the words after the `--` and writes them as the array. # # THE `--` IS LOAD-BEARING AND THIS LINE DID NOT CARRY IT UNTIL 0.40.0. A command diff --git a/docs/guide/hooks.md b/docs/guide/hooks.md index 95084e2..c1ad8d0 100644 --- a/docs/guide/hooks.md +++ b/docs/guide/hooks.md @@ -59,8 +59,10 @@ this machine and `io.toml` is the file a `git clone` delivers. There is no that. What it changes is the words: io-harness's own sentence, which names the key, the reason and the two files that may carry it, under a line saying which file was being read. Before this it arrived as a bare error string from a program -that had already exited, against a repository you had just cloned. Write the -table in `io.local.toml` or in your user file. +that had already exited, against a repository you had just cloned. **Write the +table in your user file** — since io-harness 0.74.0 `io.local.toml` is refused +too, because it sits in a root the run's own agent can write to, so one +`write_file` of it would declare a program the next start runs. --- diff --git a/docs/guide/plugins.md b/docs/guide/plugins.md index 9175905..2af4d82 100644 --- a/docs/guide/plugins.md +++ b/docs/guide/plugins.md @@ -82,9 +82,10 @@ contribute skills, templates, agents and policy, and may **not** contribute hooks, MCP servers or executables, because each of those three names a program this machine would run. A project-scoped bundle that tries is refused **whole**: it contributes nothing at all, not the half that would have been safe. Move the -`[[plugin]]` line into `io.local.toml` or into your user file and the same -directory loads completely. The rule is about which file names it, exactly as it -is for `[browser]`. +`[[plugin]]` line **into your user file** and the same directory loads +completely — `io.local.toml` is refused for the same reason since io-harness +0.74.0, being a path inside the workspace the agent can write to. The rule is +about which file names it, exactly as it is for `[browser]`. **A bundle's policy may only narrow.** Its layers may deny and may never allow: a `[policy] defaults` table in a manifest is refused by name, and a single rule diff --git a/skills/io-mcp.md b/skills/io-mcp.md index a332dba..d7577eb 100644 --- a/skills/io-mcp.md +++ b/skills/io-mcp.md @@ -36,8 +36,12 @@ The user file `~/.io-cli/io.toml` for a server that belongs to this machine; the project's `io.toml` for one the whole team should have. **A credential never goes into a committed file**: write `${env:GITHUB_TOKEN}` and let the environment carry the secret. io-harness substitutes `${env:…}`, `${file:…}` and `${cmd:…}` -and nothing else; `${cmd:…}` is refused in the project scope, because that file -travels with a clone, so it belongs in `io.local.toml` or the user file. +and nothing else; `${cmd:…}` is refused from **any file inside the workspace** — +`io.toml` travels with a clone and `io.local.toml` sits in a root the run's own +agent can write to — so it belongs in the user file and nowhere else. The same +goes for the `[[mcp]]` table that would carry it: io-harness refuses that section +from both workspace files, and refuses the whole file for it rather than the +table. ## The surface diff --git a/skills/io-provider.md b/skills/io-provider.md index 0d6bb50..a52ec30 100644 --- a/skills/io-provider.md +++ b/skills/io-provider.md @@ -41,10 +41,11 @@ machine can actually reach, and nothing here starts it. `api_key = "${env:…}"` rather than the key itself, always in a project file and preferably everywhere. io-harness substitutes `${env:…}`, `${file:…}` and -`${cmd:…}` and nothing else — `${cmd:…}` is refused in the project scope, -because that file travels with a clone, so it belongs in `io.local.toml` or the -user file — and an unset variable is a hard parse error rather than a silent -empty string — so name a variable the operator has, or say which one they need to +`${cmd:…}` and nothing else — `${cmd:…}` is refused from **any file inside the +workspace**, because `io.toml` travels with a clone and `io.local.toml` sits in a +root the run's own agent can write to, so it belongs in the user file and nowhere +else, as does the `[[provider]]` table that would carry it — and an unset variable +is a hard parse error rather than a silent empty string — so name a variable the operator has, or say which one they need to export. ## The surface diff --git a/src/acp.rs b/src/acp.rs index 7615cc0..5384314 100644 --- a/src/acp.rs +++ b/src/acp.rs @@ -1263,16 +1263,27 @@ impl Editing<'_, P> { ), )); } - let goal = prompt_text(params).ok_or(( - ErrorCode::InvalidParams, - "a prompt carries at least one text content block".to_string(), - ))?; + // **The images are decoded before the text is demanded, and the + // order is the fix.** `image: true` invites a client to send a + // prompt whose only blocks are images; computing the goal first + // returned `InvalidParams` on the `?` and none of the refusal + // sentences below were ever sent, so the operator learned neither + // what was wrong with their picture nor that a picture had been + // sent at all. + // + // **A provider that cannot look at pictures is asked first**, which + // is what `/attach` has always done (`attach::prepare` checks + // `accepts_images` before it even reads the file). Handing the + // images over anyway makes io-harness refuse the whole turn at + // request time, and this door reports that on stderr — so the + // client sees a turn that ended having said nothing, which is the + // operator's question thrown away. + let (images, refused) = prompt_images(params, self.provider.accepts_images()); // **Refused images are said, never swallowed.** They go out as // agent-message text on the session's own stream, because a client // that attached a screenshot and got an answer written without it // has no other way to learn that happened. - let (images, refused) = prompt_images(params); - for sentence in refused { + for sentence in &refused { let _ = self.outbound.send(Outgoing::Notification { method: "session/update".into(), params: json!({ @@ -1284,6 +1295,16 @@ impl Editing<'_, P> { }), }); } + // An image alone is not a prompt: it gives the model something to + // look at and nothing to answer, and inventing a question here + // would be this adapter putting words in the operator's mouth. + let goal = prompt_text(params).ok_or(( + ErrorCode::InvalidParams, + "a prompt carries at least one block with words in it — text, an embedded \ + resource or a resource link. An image may accompany them and is not a \ + question on its own." + .to_string(), + ))?; let reason = self.run(&session_id, goal, images).await; Ok(json!({ "stopReason": reason })) } @@ -1326,7 +1347,9 @@ impl Editing<'_, P> { // **The same call `/attach` makes, and it is io-harness that carries them // onto the turn.** `Session::attach` holds the images for the next turn // only, which is exactly what an ACP prompt means: the client sent them - // with this question and not with the conversation. + // with this question and not with the conversation. The caller has already + // asked the provider whether it accepts images, which is the other half of + // what `/attach` does and the half that was missing. if !images.is_empty() { session.attach(images); } @@ -1460,19 +1483,47 @@ pub fn prompt_text(params: &Value) -> Option { /// A block that cannot be taken comes back as a sentence rather than failing the /// prompt: the rest of what the client sent is still a prompt, and an editor that /// attached one unreadable screenshot should not have its question thrown away. -pub fn prompt_images(params: &Value) -> (Vec, Vec) { +pub fn prompt_images( + params: &Value, + accepts_images: bool, +) -> (Vec, Vec) { let (mut images, mut refused) = (Vec::new(), Vec::new()); let Some(blocks) = params.get("prompt").and_then(Value::as_array) else { return (images, refused); }; for block in blocks { + // **An embedded blob is spoken about too, and leaving it silent was the + // defect this function's own doc argues against.** `embeddedContext: true` + // invites a client to send one, `prompt_text` can carry only the text + // form, and a resource dropped without a word is exactly the failure the + // release exists to end — an operator whose attachment went nowhere with + // nothing on screen saying so. + if block.get("type").and_then(Value::as_str) == Some("resource") { + let resource = block.get("resource"); + if resource.and_then(|r| r.get("text")).is_none() + && resource.and_then(|r| r.get("blob")).is_some() + { + let uri = resource + .and_then(|r| r.get("uri")) + .and_then(Value::as_str) + .unwrap_or("an embedded resource"); + refused.push(format!( + "{uri} was sent as a binary resource, which a prompt cannot carry — only an \ + embedded resource's text and an image reach the model" + )); + } + continue; + } if block.get("type").and_then(Value::as_str) != Some("image") { continue; } + // Named rather than left empty: a refusal reading "so its could not be + // read" is a sentence with a hole in it, and the missing field is the + // thing the operator has to fix. let media_type = block .get("mimeType") .and_then(Value::as_str) - .unwrap_or_default(); + .unwrap_or("an image block with no `mimeType`"); let Some(data) = block.get("data").and_then(Value::as_str) else { refused.push("an image block carried no `data`".to_string()); continue; @@ -1484,6 +1535,20 @@ pub fn prompt_images(params: &Value) -> (Vec, Vec) { )); continue; }; + // **The provider is asked before the bytes are taken, which is what + // `/attach` has always done** (`attach::prepare` checks this before it + // even reads the file). Handing an image to a text-only model makes + // io-harness refuse the whole turn at request time, and this door reports + // that on stderr — so the client saw a turn that ended having said + // nothing, which is the operator's question thrown away. Found by the + // adversarial review. + if !accepts_images { + refused.push(format!( + "this provider does not accept image input, so the attached {media_type} was \ + not sent. Switch the provider, or describe the picture in words." + )); + continue; + } match io_harness::Media::attach(media_type, &bytes) { Ok(media) => images.push(media), Err(error) => refused.push(error.to_string()), diff --git a/src/configure.rs b/src/configure.rs index 1abc9ad..7f2ded2 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -310,11 +310,11 @@ pub const EXCLUDED: &[(&str, &str)] = &[ ), ( "app.io-cli.browser.binary", - "it names a program to execute. io-harness refuses a whole `[browser]` section in a \ - workspace file for exactly that reason, and it cannot apply that rule here, because \ - `[app.io-cli]` is the section it reads as an opaque value — so offering a program \ - path at a shell door with `--scope project` would put a widening act in the one \ - section nothing widening-checks", + "it names a program to execute. io-harness refuses a whole `[browser]` section from \ + any file inside the workspace for exactly that reason, and it cannot apply that \ + rule to `[app.io-cli]`, which it reads as one opaque value — so a program path is \ + written by hand, in the file you chose, rather than offered at a door that would \ + take a scope", ), ( "app.io-cli.prices.source", @@ -357,11 +357,15 @@ pub enum Kind { 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. + /// **Two keys since 0.40.0** — `app.io-cli.gates.command` is + /// `Option>` (`src/gates.rs:84`) and `app.io-cli.browser.args` is + /// io-harness's `Vec`. They have their own kind rather than being + /// folded into [`Kind::Text`] because a scalar written to either is a value + /// io-harness cannot read back. The generic "type a value" editor 0.28.0 + /// replaced would have written exactly that. + /// + /// A list is the one kind whose value routinely begins with a dash, so the + /// shell door takes its words after a `--` — see `crate::manage`. List, /// Text no menu can hold: a substring to look for, a rubric, a URL. Three keys. Text, @@ -494,6 +498,20 @@ pub const APP_PREFIX: &str = "app.io-cli."; /// io-harness's own words. A refusal at write time is strictly better than a /// silently wrong file. pub fn source_for(key: &str, words: &[String]) -> Result { + // **[`EXCLUDED`] is enforced here, and until this was written it enforced + // nothing.** The list was added in 0.40.0 as the other half of the gate that + // walks the settings struct, and the adversarial review found that both + // `/config` doors reached the fall-through below regardless: `kind_of` answers + // `None` for `app.io-cli.browser.binary`, so it fell to the arm that quotes any + // unrecognised `app.io-cli.*` word and produced a perfectly valid string for an + // `Option` field. `io config set app.io-cli.browser.binary /usr/bin/x + // --scope project` succeeded and took effect — the exact act the entry's own + // reason says must not be offered, in the one section io-harness reads + // opaquely and never widening-checks. The reason strings had no reader at all; + // now they are what an operator is told. + if let Some((_, why)) = EXCLUDED.iter().find(|(named, _)| *named == key) { + return Err(format!("`{key}` is not set from here: {why}")); + } let kind = kind_of(key); if matches!(kind, Some(Kind::Machine)) { return Err(format!( @@ -502,9 +520,10 @@ pub fn source_for(key: &str, words: &[String]) -> Result { 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. + // The two keys whose value is a list — `app.io-cli.gates.command` and, since + // 0.40.0, `app.io-cli.browser.args` — so the remaining words are the value + // rather than a mistake. A scalar written to either is a value io-harness + // cannot read back. if matches!(kind, Some(Kind::List)) { if words.is_empty() { // **No command word in the sentence, because two doors reach it @@ -915,9 +934,14 @@ pub fn spell_value(kind: &Kind, value: &str) -> String { let bare = value.trim().trim_matches('"'); match kind { Kind::Flag | Kind::Number { .. } => bare.to_string(), - // Already an inline table by the time it is here — `source_for` is what - // turns a number of seconds into one, and re-wrapping it would produce a - // table inside a string. + // **Unreachable today, and written as the correct answer rather than as a + // guess.** The one caller is the session's value picker, and `value_rows` + // returns `None` for a duration before any such pick exists — so nothing + // routes here. It is spelled anyway because the match is exhaustive over + // io-cli's own enum: a wildcard would quote a duration into a string the + // moment a later release gave the kind a picker, which is the failure this + // whole function exists to prevent. By then the value is already the + // inline table `source_for` built, so passing it through is right. Kind::Duration => bare.to_string(), Kind::List => { let words: Vec<&str> = bare.split_whitespace().collect(); @@ -1127,14 +1151,59 @@ pub fn writable_scopes( key: &str, value: &str, ) -> Vec<(Scope, std::path::PathBuf)> { - let widening = widens_workspace(key, value); + let refused_in_workspace = widens_workspace(key, value) || user_scope_only(key); [Scope::User, Scope::Project, Scope::Local] .into_iter() - .filter(|scope| !(widening && matches!(scope, Scope::Project | Scope::Local))) + .filter(|scope| !(refused_in_workspace && matches!(scope, Scope::Project | Scope::Local))) .filter_map(|scope| scope_path(root, scope).map(|path| (scope, path))) .collect() } +/// Keys io-cli itself keeps out of a file inside the workspace, and why. +/// +/// **io-harness cannot hold this line and it is this crate's to hold** (0.40.0). +/// The harness refuses a whole top-level `[browser]` section from any file inside +/// a workspace, because it names a program to execute and `io.toml` arrives with a +/// `git clone` while `io.local.toml` sits in a root the run's own agent can write +/// to. `[app.io-cli.browser]` is the same table under a section the harness reads +/// as one opaque value, so `refuse_widening` never sees it — and this release is +/// what built a door to it. +/// +/// `binary` is on [`EXCLUDED`] and is refused at both doors outright. `args` is a +/// catalogue row, because an operator has to be able to set it and the `[browser]` +/// section is theirs — but the browser's argument vector is where a +/// `--proxy-server=https://user:pass@host` or a `--load-extension` goes, which +/// io-harness redacts from its own `Debug` for exactly that reason +/// (`io-harness-0.83.0/src/browser.rs:163`). Committing one is the same act as +/// committing the binary, one word further along. +/// +/// A pair rather than a bare list, so the refusal says why. Found by the +/// adversarial review, which noticed the release had written down the argument +/// against this for `binary` and then built the door for `args`. +pub const USER_SCOPE_ONLY: &[(&str, &str)] = &[( + "app.io-cli.browser.args", + "it is the argument vector of a program, which is where a proxy carrying \ + credentials or a loaded extension goes — io-harness refuses a whole `[browser]` \ + section from a file inside the workspace for that reason, and cannot apply the \ + rule to `[app.io-cli]`, which it reads as one opaque value; write it with \ + `--scope user` for yourself", +)]; + +/// Is `key` one io-cli keeps out of a workspace file whatever its value? +#[must_use] +pub fn user_scope_only(key: &str) -> bool { + USER_SCOPE_ONLY.iter().any(|(named, _)| *named == key) +} + +/// Why `key` may not be written inside the workspace, if it may not. +#[must_use] +pub fn why_user_scope_only(key: &str) -> Option<&'static str> { + USER_SCOPE_ONLY + .iter() + .find(|(named, _)| *named == key) + .map(|(_, why)| *why) +} + /// 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/src/main.rs b/src/main.rs index e7f4775..88c8386 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9100,10 +9100,13 @@ fn undo_whole_turn( // function's own comment warns about, and this is the half that was // wrong. // - // **After the record loop, not before.** `Events::forget` also resets - // the blank-line state that loop writes through, so forgetting first - // would run the undo's own report through a renderer that had just - // been told the scrollback was empty. + // After the record loop by convention rather than by necessity, and + // an earlier draft of this comment claimed otherwise: it said + // `Events::forget` resets blank-line state the loop writes through. + // It does not — `App::record` builds a line from the theme and pushes + // it onto `pending`, and reaches `Events` at no point — so the + // ordering is free. Caught by the adversarial review, which is right + // that a reason invented for a correct line is still a false comment. app.events.forget(); app.servers.forget(); } diff --git a/src/manage.rs b/src/manage.rs index 628e3e8..adfa9db 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -1192,9 +1192,15 @@ impl Args { fn no_command(&self, verb: &str) -> Result<(), String> { match &self.opaque { None => Ok(()), + // **The closing clause used to name a server, on every verb that + // reaches this** — right for `mcp add`, which is what it was written + // for, and wrong for `config set`, where a `--` means the operator + // thought they were writing a command line. It says what the `--` is + // for instead, which is true wherever this is reached. 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", + to go; `--` carries a program's own argument vector, and only a verb that takes \ + one reads it", rest.join(" ") )), } @@ -1652,6 +1658,19 @@ fn config_set(args: &Args) -> Result { // reason. `io.local.toml` is not committed, but it sits in the workspace root // a run's own agent can write to, so one `write_file` of an unremarkable name // was an escalation. The user scope is the only destination left. + // **io-cli's own line, beside io-harness's** (0.40.0). The harness refuses a + // top-level `[browser]` from a workspace file because it names a program; it + // cannot apply that rule to `[app.io-cli.browser]`, which it reads as one + // opaque value. So the browser's argument vector — where a proxy carrying + // credentials goes — is held to the user scope here, at the same door and in + // the same shape as the widening refusal below. + if matches!(scope, Some(Scope::Project | Scope::Local)) { + if let Some(why) = crate::configure::why_user_scope_only(&key) { + return Err(format!( + "`{key}` is not written inside the workspace: {why}" + )); + } + } if matches!(scope, Some(Scope::Project | Scope::Local)) && crate::configure::widens_workspace(&key, &value) { diff --git a/tests/acp.rs b/tests/acp.rs index 7f1d095..035b6e1 100644 --- a/tests/acp.rs +++ b/tests/acp.rs @@ -1941,12 +1941,15 @@ fn f7_an_image_block_becomes_media_and_a_bad_one_is_said_rather_than_swallowed() // A 1×1 PNG, base64 as a client would send it. const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; - let (images, refused) = acp::prompt_images(&json!({ - "prompt": [ - { "type": "text", "text": "what is in this?" }, - { "type": "image", "mimeType": "image/png", "data": PNG }, - ], - })); + let (images, refused) = acp::prompt_images( + &json!({ + "prompt": [ + { "type": "text", "text": "what is in this?" }, + { "type": "image", "mimeType": "image/png", "data": PNG }, + ], + }), + true, + ); assert_eq!(images.len(), 1, "one image block is one attachment"); assert_eq!( images[0].media_type, "image/png", @@ -1960,13 +1963,16 @@ fn f7_an_image_block_becomes_media_and_a_bad_one_is_said_rather_than_swallowed() // What cannot be taken is said, and the rest of the prompt survives it. An // editor that attached one unreadable screenshot must not lose its question. - let (images, refused) = acp::prompt_images(&json!({ - "prompt": [ - { "type": "image", "mimeType": "image/svg+xml", "data": "PHN2Zy8+" }, - { "type": "image", "mimeType": "image/png", "data": "not base64!" }, - { "type": "image", "mimeType": "image/png" }, - ], - })); + let (images, refused) = acp::prompt_images( + &json!({ + "prompt": [ + { "type": "image", "mimeType": "image/svg+xml", "data": "PHN2Zy8+" }, + { "type": "image", "mimeType": "image/png", "data": "not base64!" }, + { "type": "image", "mimeType": "image/png" }, + ], + }), + true, + ); assert!(images.is_empty()); assert_eq!( refused.len(), @@ -1986,4 +1992,82 @@ fn f7_an_image_block_becomes_media_and_a_bad_one_is_said_rather_than_swallowed() refused[2].contains("`data`"), "a block with no data at all says which field is missing: {refused:?}", ); + + // **A text-only provider is asked before the bytes are taken.** Handing an + // image to one makes io-harness refuse the whole turn at request time, and + // `io acp` reports that on stderr — so the client saw a turn that ended + // having said nothing, which is the operator's question thrown away. This is + // the guard `/attach` has always had (`attach::prepare` checks it before it + // even reads the file) and the ACP door did not. Found by the adversarial + // review. + // + // Sabotage: drop the `accepts_images` arm from `prompt_images`. The image is + // attached and nothing is said. + let (images, refused) = acp::prompt_images( + &json!({ + "prompt": [ + { "type": "text", "text": "what is in this?" }, + { "type": "image", "mimeType": "image/png", "data": PNG }, + ], + }), + false, + ); + assert!( + images.is_empty(), + "an image was attached for a provider that cannot look at one", + ); + assert_eq!(refused.len(), 1, "{refused:?}"); + assert!( + refused[0].contains("does not accept image input"), + "the refusal names the reason and the fix rather than the format: {refused:?}", + ); +} + +/// **F7 — an embedded blob is spoken about rather than dropped.** +/// +/// `embeddedContext: true` invites a client to send one, `prompt_text` can carry +/// only the text form, and this function's own doc calls silent skipping the +/// defect the release exists to fix. Images got spoken refusals from the first +/// draft; blobs did not, which the adversarial review caught. +#[test] +fn f7_a_binary_resource_is_named_rather_than_skipped() { + let (images, refused) = acp::prompt_images( + &json!({ + "prompt": [ + { "type": "text", "text": "what is this?" }, + { + "type": "resource", + "resource": { + "uri": "file:///w/report.pdf", + "mimeType": "application/pdf", + "blob": "JVBERi0=", + }, + }, + ], + }), + true, + ); + assert!(images.is_empty(), "a blob is not an image"); + assert_eq!(refused.len(), 1, "{refused:?}"); + assert!( + refused[0].contains("file:///w/report.pdf"), + "the sentence names the resource that went nowhere: {refused:?}", + ); + + // A text resource is carried rather than complained about — it is the case + // that works, and a gate that refused both would be asserting the opposite of + // the feature. + let (_, refused) = acp::prompt_images( + &json!({ + "prompt": [{ + "type": "resource", + "resource": { "uri": "file:///w/a.rs", "text": "fn main() {}" }, + }], + }), + true, + ); + assert!( + refused.is_empty(), + "a text resource is carried: {refused:?}" + ); } diff --git a/tests/configure.rs b/tests/configure.rs index 2269731..2a8fec0 100644 --- a/tests/configure.rs +++ b/tests/configure.rs @@ -639,18 +639,132 @@ fn f1_every_settable_app_key_round_trips_through_the_shell_door() { ); } - // The table is the catalogue's own list, so a key made settable and not - // exercised here fails rather than shipping untested. `theme` and the keys - // that were already settable before 0.40.0 are covered by the tests above; - // what this asserts is that nothing in the catalogue is settable in principle - // and unwritten in practice. - for key in configure::CATALOGUE - .iter() - .filter(|key| key.starts_with("app.io-cli.containment.") || key.contains(".browser.")) - { + // **Every key 0.40.0 made settable, and the filter used to miss two of them.** + // It selected the containment and browser rows by prefix, which is eleven of + // the thirteen — `reference_catalogue` and `prices.models`, the two the + // release calls the sharpest, could have been dropped from the table above + // without failing anything while this comment claimed otherwise. Named + // outright now: a list derived from a prefix is a list that quietly changes + // shape when a key is added under a different one. Found by the adversarial + // review. + for key in [ + "app.io-cli.reference_catalogue", + "app.io-cli.prices.models", + "app.io-cli.browser.headless", + "app.io-cli.browser.width", + "app.io-cli.browser.height", + "app.io-cli.browser.timeout_secs", + "app.io-cli.browser.args", + "app.io-cli.containment.max_total_agents", + "app.io-cli.containment.max_concurrent_agents", + "app.io-cli.containment.max_depth", + "app.io-cli.containment.max_total_tokens", + "app.io-cli.containment.max_total_cost", + "app.io-cli.containment.max_total_duration", + ] { + assert!( + configure::CATALOGUE.contains(&key), + "`{key}` is one of the thirteen this release made settable and it is not in \ + the catalogue", + ); + assert!( + written.iter().any(|(named, _)| *named == key), + "`{key}` is one of the thirteen and this gate never writes it", + ); + } +} + +/// **F1 — an excluded key is refused at the writer, not merely absent from a +/// list.** +/// +/// `configure::EXCLUDED` was added as the other half of the settings-struct walk +/// and, as the adversarial review found, it enforced nothing: `kind_of` answers +/// `None` for `app.io-cli.browser.binary`, so both `/config` doors fell through to +/// the arm that quotes any unrecognised `app.io-cli.*` word and produced a +/// perfectly valid string for an `Option` field. `io config set +/// app.io-cli.browser.binary /usr/bin/x --scope project` succeeded and took +/// effect — the exact act the entry's own reason says must not be offered, in the +/// one section io-harness reads opaquely and never widening-checks. +/// +/// The reason strings had no reader at all. Now they are what the operator is +/// told, which is also what stops them going stale. +/// +/// Sabotage: delete the `EXCLUDED` lookup from `source_for`. Every row goes red. +#[test] +fn f1_an_excluded_key_is_refused_by_the_writer_with_its_own_reason() { + for (key, why) in configure::EXCLUDED { + let refusal = configure::source_for(key, &["anything".to_string()]) + .expect_err("an excluded key is not written from either door"); + assert!( + refusal.contains(key), + "the refusal for `{key}` does not name it: {refusal}", + ); + assert!( + refusal.contains(why), + "the refusal for `{key}` does not carry the reason `EXCLUDED` records, so the \ + reason is data nothing reads and nothing keeps true: {refusal}", + ); + } + + // The one that matters most, spelled out: it is a program path, in the section + // io-harness cannot widening-check. + let refusal = configure::source_for( + "app.io-cli.browser.binary", + &["/usr/bin/chromium".to_string()], + ) + .expect_err("a program path is not set from a shell door"); + assert!(refusal.contains("names a program to execute"), "{refusal}"); +} + +/// **F6 — the browser's argument vector is written to the user scope alone.** +/// +/// The same argument the release wrote down for `binary` and then did not apply +/// one field along. `args` is where a `--proxy-server=https://user:pass@host` or a +/// `--load-extension` goes; io-harness redacts it from its own `Debug` for exactly +/// that shape, and refuses the whole `[browser]` section from any file inside the +/// workspace — a rule it cannot apply to `[app.io-cli.browser]`, which it reads as +/// one opaque value. Committing the arguments is the same act as committing the +/// binary, one word further along. +/// +/// Sabotage: empty `configure::USER_SCOPE_ONLY`. Both halves go red. +#[test] +fn f6_the_browser_argument_vector_is_user_scope_only() { + let s = scopes("", "", ""); + + let offered: Vec = configure::writable_scopes( + s.root.path(), + "app.io-cli.browser.args", + "[\"--disable-gpu\"]", + ) + .into_iter() + .map(|(scope, _)| scope) + .collect(); + assert_eq!( + offered, + vec![Scope::User], + "the `/config` scope picker offers a workspace file for the browser's arguments", + ); + + // And the shell door refuses the scope by name rather than writing it. + let refused = io_cli::manage::parse(&io_cli::manage::tokens( + "config set app.io-cli.browser.args --scope project -- --proxy-server=http://x", + )) + .expect_err("a workspace scope is refused for the browser's arguments"); + assert!( + refused.contains("argument vector of a program"), + "the refusal has to say why, or it reads as an arbitrary restriction: {refused}", + ); + + // Every entry of the list is refused the same way, so a second one added later + // inherits the gate rather than needing its own. + for (key, _) in configure::USER_SCOPE_ONLY { + assert!( + configure::user_scope_only(key), + "`{key}` is on the list and the predicate does not answer for it", + ); assert!( - written.iter().any(|(named, _)| named == key), - "`{key}` is offered by the catalogue and this gate never writes it", + configure::why_user_scope_only(key).is_some(), + "`{key}` is refused with no reason to give the operator", ); } } diff --git a/tests/docs.rs b/tests/docs.rs index eacbf18..fee0e9e 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -441,9 +441,9 @@ fn unnamed_leaves(value: &serde_json::Value, at: &str, out: &mut Vec) { /// `CATALOGUE` or by `EXCLUDED` and nothing else. /// /// Sabotage: delete one entry from `CATALOGUE`. This goes red naming that key, -/// and `the_readme_documents_every_key_of_the_io_cli_section` and -/// `every_catalogue_key_is_documented` both stay green — which is the proof the -/// two directions are not the same test. +/// and `the_readme_documents_every_key_of_the_io_cli_section` here and +/// `f2_the_catalogue_is_documented_rather_than_invented` in `tests/configure.rs` +/// both stay green — which is the proof the two directions are not the same test. #[test] fn f2_every_settings_key_is_named_by_one_of_the_two_lists() { let value = serde_json::to_value(every_setting()).expect("[app.io-cli] serializes"); @@ -3588,6 +3588,80 @@ fn n2_no_shipped_page_offers_the_local_file_as_the_place_to_widen() { } } + // **The property, not five needles.** The sweep above is a list of phrases, + // and a list cannot notice a sixth spelling — which is what happened: four + // more pages and two agent-read skills still sent an operator to + // `io.local.toml` for a REFUSED SECTION rather than for a widening value, and + // every one of them passed the needles. So this is the shape instead: a + // paragraph that names one of the sections io-harness refuses from a workspace + // file, and names `io.local.toml` within it, is offering the file for + // something it will be refused for — whichever verb the sentence happens to + // use. Found by the adversarial review. + // + // Paragraph-scoped rather than whole-file, because a page may legitimately + // mention both far apart: `docs/guide/configuration.md` explains the scope + // rule in one place and lists the files in another. + for (name, text) in &pages { + for paragraph in text.split("\n\n") { + if !paragraph.contains("io.local.toml") { + continue; + } + // **The escape hatch is two exact phrases, and the first draft's was + // four loose ones that made this whole gate vacuous.** It excused any + // paragraph containing "refused", "may not" or "nowhere else" — and + // the stale `docs/guide/hooks.md` paragraph opens "A project-scoped + // file **may not** declare `[[hook]]`" and then offers + // `io.local.toml`, so the gate passed over the very page it was + // written for. Verified by restoring that page from git and watching + // this test stay green. + // + // What separates the corrected form from the stale one is not whether + // a refusal is mentioned — both mention one — but WHICH FILE the + // refusal is about. The corrected paragraphs say the rule covers any + // file inside the workspace, or cite the io-harness release that made + // it so. Neither phrase appears in any of the stale forms. + // + // Whitespace-normalised because a shipped page wraps, and + // `skills/io-provider.md` carries "inside the\nworkspace" across a + // line break. **The comment marker goes too**: one of these pages is + // `docs/config.example.toml`, where every line of prose opens with a + // `#`, so a phrase spanning two lines reads "inside the # workspace" + // and matched nothing — which made the gate fire on a paragraph that + // says exactly the right thing. + let lowered = paragraph + .to_lowercase() + .lines() + .map(|line| line.trim_start().trim_start_matches('#')) + .collect::>() + .join(" ") + .split_whitespace() + .collect::>() + .join(" "); + if lowered.contains("0.74.0") || lowered.contains("inside the workspace") { + continue; + } + for section in [ + "[[hook]]", + "[[plugin]]", + "[[mcp]]", + "[[lsp]]", + "[[provider]]", + "[browser]", + "${cmd:", + ] { + assert!( + !paragraph.contains(section), + "{name} names `{section}` and offers `io.local.toml` in the same \ + paragraph without saying that file is refused for it. io-harness \ + refuses every one of these sections from any file inside the \ + workspace, and refuses the WHOLE FILE rather than the section — so \ + the paragraph sends an operator to a configuration that will not \ + parse:\n{paragraph}", + ); + } + } + } + // And the three pages that carry the rule say what it is now, so the negative // sweep above cannot be satisfied by deleting the subject. for name in [ diff --git a/tests/live.rs b/tests/live.rs index f17e3de..ad7cfca 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -4100,8 +4100,22 @@ async fn live_f6_a_withheld_tool_is_refused_by_the_mask_and_says_so() { if layer.as_deref() == Some("turn tool mask") && target == "write_file" ) }); + // **A refused call still commits a step, and reading the tool name alone + // called that a success** (found by the 0.40.0 live run, which is the first + // time a model actually attempted the withheld call). io-harness records the + // refusal as `Step { decision: "write_file refused: withheld from this turn", + // tool_call: "write_file:{…}" }` — so the mask working produced exactly the + // event this predicate was reading as the mask having failed, and the arm + // above it says the refusal is one of the two outcomes that PROVE the mask. + // + // The decision is what separates them, and it is io-harness's own sentence + // rather than a needle typed here: a refused step carries the refusal in it. let committed = events.iter().any(|event| { - matches!(&event.kind, EventKind::Step { tool_call, .. } if tool_call.starts_with("write_file:")) + matches!( + &event.kind, + EventKind::Step { tool_call, decision, .. } + if tool_call.starts_with("write_file:") && !decision.contains("refused") + ) }); println!("refused: {refused} committed a write_file step: {committed}"); From 87a6d18103499bfc291169ca4053db9f8fb07c78 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Mon, 7 Sep 2026 09:49:59 +0530 Subject: [PATCH 9/9] docs(changelog): the two browser keys held to the file you own --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53f2fe7..5be9aba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,14 @@ Two shipped interactions change. Neither is a correction you will not notice. catalogue — a list cannot notice what is missing from it. That walk exists now, and it found a fourteenth key while it was being written. + **Two keys of `[app.io-cli.browser]` are held to the file you own.** + `binary` names a program to execute and is written by hand rather than offered + at either `/config` door; `args` is that program's argument vector — where a + proxy carrying credentials or a loaded extension goes — and is settable only + with `--scope user`. io-harness refuses a whole top-level `[browser]` section + from any file inside the workspace for exactly this reason, and cannot apply the + rule to `[app.io-cli]`, which it reads as one opaque value. + - **`max_total_duration` is settable at all.** It deserializes as a `Duration`, so what the file has to carry is `{ secs = N, nanos = 0 }` and no scalar you type is a value for it. `io config set app.io-cli.containment.max_total_duration 3600`