Skip to content

fix: OpenCode config safety (#1585,#1586), policy-aware drift (#1596,#1598,#1599), verbatim turn budget (#1582), config/CLI/dispatch honesty (#1605,#1604,#1602) - #1603

Merged
yvgude merged 5 commits into
mainfrom
fix/issue-batch-1582-1599
Aug 29, 2026
Merged

Conversation

@yvgude

@yvgude yvgude commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Six field-reported defects, each with regression tests. Branch is cut from github/main (5a90893092), so the diff is 16 files — not the whole lineage.

What was wrong

#1586 — an OpenCode config could be silently replaced by a lean-ctx-only scaffold.
Both writers (install_opencode_hook_with_mode and register_opencode_instructions) started from an empty object whenever parse_jsonc failed, wiping providers, models, plugins and foreign MCP entries — and the command still reported success. They now fail closed: an existing config that will not parse is left untouched and the reason is printed. parse_jsonc also tolerates a single leading UTF-8 BOM, which Windows editors emit routinely and serde_json rejects — that rejection was one of the ways a healthy config got read as "corrupt".

#1585 — OpenCode accepts both opencode.json and opencode.jsonc.
lean-ctx hardcoded one name per call site, so install, doctor, setup and uninstall could each target a different file than the user actually has. New core::opencode_config resolver (exactly one exists → that file; both → .json, flagged ambiguous; neither → .json for creation) is now the single source of truth for all of them. Uninstall visits both names so a lean-ctx entry is never orphaned in the file we did not install to.

#1596 — intentionally absent rules files were reported as drift.
A missing global rules file counted as unhealthy even when rules_injection = "off" or rules_scope = "project" made its absence deliberate. Claude Code's dedicated check already reasoned this way; check_rules_file_for_policy gives Cline, Cline CLI, Roo, Windsurf and the rest the same rule.

#1598 — Windows setup --fix loop.
The generated bash wrappers hold an MSYS path (/c/Users/Max/.cargo/bin/lean-ctx.exe) because that is what bash needs, while the expected binary is native (C:\Users\Max\…). Byte comparison called a correct wrapper stale and sent Windows users into a setup --fix loop that rewrote the file to a byte-identical result. same_windows_path compares the two spellings; anything not drive-qualified in one of those two forms returns false, so ordinary Unix paths are never conflated.

#1599rules_injection = "off" was only half honoured.
That setting is an opt-out from lean-ctx steering the agent. The initialize instructions field is the same steering block delivered over a different channel, so honouring the setting in the files while still shipping ~780 tokens of "ALWAYS use ctx_*, NEVER use native Read/Grep" on every session start made the setting look broken. Off is now off on every channel. The existing #1447 carve-out for Antigravity/Gemini CLI is unchanged.

#1582 — the documented recovery path out of compression did not work.
The turn budget (#1306) capped every response at 4096 tokens, including a read the caller explicitly asked to be verbatim. raw=true is exactly what the compression annotations and the server instructions name as the way back to the original bytes, so that path was silently unreachable for any file above ~16 KB.

Explicit verbatim requests (raw=true or mode="raw", on ctx_read and ctx_shell) now use a separate turn_fresh_limit_verbatim — default 32768, env LEAN_CTX_TURN_FRESH_LIMIT_VERBATIM, 0 = unlimited, and never below the ordinary limit so raising turn_fresh_limit can't make a raw read deliver less than a compressed one. Everyday modes keep the 4096-token backstop: exempting full, lines:, anchored and friends would switch the backstop off for most traffic, and their truncation banner already points at a working lines= recovery.

Measured, not asserted

235 KB / 6000-line markdown file, driven through the stdio server:

request before after
{mode:"raw", raw:true} 4052 tokens / 462 lines 32740 tokens / 3722 lines
{mode:"raw"} 4052 tokens 32740 tokens
{mode:"full"} 4052 tokens 4052 tokens (unchanged, banner intact)

Side note for the original report: a truncation banner is emitted on this build ([… truncated at ~N of M tokens — use ctx_read with lines= …]). The defect was the ceiling ignoring an explicit verbatim request, not a missing banner.

Verification

  • cargo test --lib10508 passed, 0 failed, 22 ignored
  • cargo clippy --lib --all-features → clean
  • scripts/preflight.sh fast → 7/8. The one failure is git diff --check evaluated over the divergent merge-base (437132fa, 3466 pre-existing files from the other lineage); git diff --check on this branch's own changes is empty.

New tests: 6 in hooks::agents::opencode::config_preservation_tests, 9 in doctor::integrations::wiring::tests, 5 in core::opencode_config, 3 in core::config::logic::verbatim_budget_tests, 3 in server::call_tool::pipeline::savings_tests, 2 in core::jsonc::tests.

#1605config set llm.api_key reported a save it discarded

The [llm] schema block was hand-written with literal defaults instead of being derived from cfg.llm, and had drifted three ways: an api_key key no struct field backs (so set_by_key wrote it into the TOML table and the Config round-trip silently dropped it — after the CLI printed "Updated"), a model default of llama3.2 against the struct's qwen2.5-coder:1.5b, and a missing base_url. Defaults now come from cfg.llm.* like the neighbouring [decision_loop] block, api_key is gone (config set llm.api_key … fails with Unknown config key; the section description names OPENROUTER_API_KEY / ANTHROPIC_API_KEY), and llm_schema_keys_match_the_llm_config_struct guards the class so the next drift fails in CI instead of eating a secret. config-keys.md regenerated.

#1604ctx_call answered "path is required" about a parameter the caller supplied

The envelope parser accepted the inner arguments only under arguments (plus the #658 guard that turns an args/params/parameters/arg wrapper into a "did you mean arguments" hint). Agents also flatten the call — ctx_call(name="ctx_edit", path=…, old_string=…) — and that reached the inner tool with no arguments at all, so the inner tool complained about its own first required parameter. Probing a shipped 3.9.20 binary shows the signature: flattened ctx_glob → "pattern is required", flattened ctx_edit → "path is required" — the message follows whichever tool you name, which is what an argument-less forward looks like, not the registry-ordering bug the report suspected. name is the only key ctx_call reserves, so a flattened call is unambiguous and the rest is now forwarded; nested calls and the #658 hint are unchanged. With arguments arriving, an inner tool that genuinely isn't registered now reaches the existing Unknown tool: … + nearest-match path (#712), which is what the reporter asked for. ctx_call_forwards_flattened_inner_arguments pins all four shapes; it drives ctx_read deliberately, because that tool is on every built-in role's allowlist and so a sibling test leaving a non-default role in the global slot can't turn the assertions into [ROLE DENIED].

#1602lean-ctx watch was still advertised after its removal

The TUI went with the dead-module cleanup in 3.9.20 (de96cc5), but --help, the completion spec and watch --help all still described it as a working command, while bare watch exited 1 with "The live TUI dashboard has been removed." and named no replacement. All four surfaces now carry the same notice and point at lean-ctx dashboard (web UI, same event stream), lean-ctx cep (metrics as text) and lean-ctx index watch (the index watcher). watch --help → stdout, exit 0; bare watch → stderr, exit 1. The completion entry is hidden rather than deleted, with a "Removed — see lean-ctx dashboard" description. Guarded by full_help_does_not_advertise_the_removed_tui_dashboard and removed_watch_command_is_not_advertised.

Fixes #1582
Fixes #1585
Fixes #1586
Fixes #1596
Fixes #1598
Fixes #1599
Fixes #1602
Fixes #1604
Fixes #1605

🤖 Generated with Claude Code

yvgude and others added 4 commits August 29, 2026 09:20
…, verbatim budget

Six field-reported defects, each with regression tests.

#1586 — OpenCode config could be silently replaced by a lean-ctx-only
scaffold. Both writers (`install_opencode_hook_with_mode` and
`register_opencode_instructions`) started from an empty object whenever
`parse_jsonc` failed, wiping providers, models, plugins and foreign MCP
entries — and still reported success. They now fail closed: an existing
config that will not parse is left untouched and the reason is printed.
`parse_jsonc` also tolerates a single leading UTF-8 BOM, which Windows
editors emit routinely and serde_json rejects.

#1585 — OpenCode accepts `opencode.json` and `opencode.jsonc`. lean-ctx
hardcoded one name per call site, so install, doctor, setup and uninstall
could each target a different file than the user actually has. New
`core::opencode_config` resolver (exactly-one wins, both → `.json` flagged
ambiguous, neither → `.json` for creation) is now the single source of
truth for all of them; uninstall visits both names so no entry is orphaned.

#1596 — a missing global rules file was reported as integration drift even
when `rules_injection = "off"` or `rules_scope = "project"` made its absence
intentional. Claude Code's check already reasoned this way;
`check_rules_file_for_policy` gives every other editor the same rule.

#1598 — the generated bash wrappers hold an MSYS path
(`/c/Users/Max/.cargo/bin/lean-ctx.exe`) while the expected binary is native
(`C:\Users\Max\...`). Byte comparison called a correct wrapper stale and sent
Windows users into a `setup --fix` loop that rewrote the file to a
byte-identical result. `same_windows_path` compares the two spellings;
ordinary Unix paths are never conflated.

#1599 — `rules_injection = "off"` is an opt-out from lean-ctx steering the
agent. The initialize `instructions` field is that same steering block over
another channel, so shipping ~780 tokens of it anyway made the setting look
broken. Off is now off on every channel.

#1582 — the turn budget (#1306) capped every response at 4096 tokens,
including a read the caller explicitly asked to be verbatim. `raw=true` is
what the compression annotations and the server instructions name as the way
back to the original bytes, so the documented recovery path was unreachable
above ~16 KB. Explicit verbatim requests (`raw=true` / `mode="raw"` on
ctx_read and ctx_shell) now use `turn_fresh_limit_verbatim` (default 32768,
`LEAN_CTX_TURN_FRESH_LIMIT_VERBATIM`, 0 = unlimited, never below the ordinary
limit). Everyday modes keep the backstop — exempting `full`/`lines:`/
`anchored` would switch it off for most traffic.

Measured on a 235 KB / 6000-line markdown file via the stdio server:
`{mode:"raw", raw:true}` delivered 4052 tokens before, 32740 after;
`{mode:"full"}` is unchanged at the 4052-token backstop, banner intact.

Verification: cargo test --lib → 10508 passed, 0 failed.
cargo clippy --lib --all-features → clean. preflight fast → 7/8; the single
failure is `git diff --check` over the divergent merge-base (3466
pre-existing files from the other lineage), and `git diff --check` on this
branch's own changes is empty.

Fixes #1582
Fixes #1585
Fixes #1586
Fixes #1596
Fixes #1598
Fixes #1599

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cargo-deny's advisories check fails on every open PR: chacha20 0.10.1 was
yanked from crates.io and reaches the tree twice (rmcp → rand 0.10.2, and
pdf-extract → lopdf → rand 0.10.2). Lockfile-only bump; `cargo deny check
advisories` is ok afterwards. Unlike the arrayref case documented in
deny.toml, this yank resolves upward with no supply-chain concern, so no
ignore entry is added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`lean-ctx config set llm.api_key <secret>` printed "Updated" and exited 0
while writing nothing. `set_by_key` accepts any key the schema names, writes
it into the TOML table, then round-trips through `Config` — and serde drops
the field, because `LlmConfig` has no `api_key`. The credential is read from
OPENROUTER_API_KEY / ANTHROPIC_API_KEY and from nowhere else. Reporting a
successful save for a discarded secret is the part worth fixing regardless of
the LLM feature's status.

The `[llm]` schema block was hand-written with literal values instead of
being derived from `cfg.llm`, and had drifted three ways: the phantom
`api_key`, a `model` default of `llama3.2` against the struct's
`qwen2.5-coder:1.5b`, and a missing `base_url` — the one field that actually
overrides `effective_base_url()`. Every default now comes from `cfg.llm.*`,
the way the neighbouring `[decision_loop]` block already does, so it cannot
drift again. `api_key` is gone: `config set llm.api_key …` now fails loudly
with `Unknown config key`, and the section description names the two
environment variables that do supply the credential.

`llm_schema_keys_match_the_llm_config_struct` guards the whole class — every
`[llm]` schema key must name a real serialised field and carry that field's
default — so the next drift fails in CI instead of eating a secret.

docs/reference/generated/config-keys.md regenerated (gen_docs).

Verification: cargo test --lib, cargo clippy --lib --all-features -D warnings
clean, cargo fmt --check clean, gen_docs --check clean.

Fixes #1605

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng the removed watch TUI

#1604 — ctx_call(name="ctx_edit", path=…, old_string=…)

Agents flatten the call as often as they nest it. The envelope parser only
accepted `arguments` (plus the #658 typo guard for args/params/parameters/arg),
so a flattened call reached the inner tool with NO arguments at all — and the
inner tool answered "path is required", naming a parameter the caller had
demonstrably just supplied. That message sent the reporter looking for a
registry-ordering bug that does not exist; the probe against a shipped 3.9.20
binary shows the same behaviour for ctx_glob ("pattern is required") and
ctx_edit ("path is required"), which is the flat-envelope signature, not a
name-collision one.

`name` is the only key ctx_call reserves, so a flattened call is unambiguous:
everything except `name` is now forwarded as the inner arguments. The nested
form is untouched, and the #658 misspelling hint still fires for an `args`
wrapper — forwarding that would hand the inner tool a bogus `args` parameter
instead of telling the caller about the typo.

`ctx_call_forwards_flattened_inner_arguments` pins all four shapes. It drives
`ctx_read` deliberately: that tool is on every built-in role's allowlist, so a
sibling test leaving a non-default role in the global slot cannot turn the
assertions into [ROLE DENIED].

#1602 — `lean-ctx watch`

The live TUI dashboard went away with the dead-module cleanup in 3.9.20
(de96cc5), but the surfaces disagreed about it. `--help` still listed
"watch — Live TUI dashboard (real-time event stream)", shell completions
still offered it, and `watch --help` still printed "Usage: lean-ctx watch /
Live TUI dashboard" — while the command itself exited 1 with a bare "The live
TUI dashboard has been removed." and no replacement. Every documented path
led the user to a command that announces its own absence.

All three surfaces now agree and name the replacement: `lean-ctx dashboard`
for the same event stream in the web UI, `lean-ctx cep` for the metrics as
text, `lean-ctx index watch` for the index watcher. `watch --help` prints the
notice on stdout and exits 0; a bare `watch` prints it on stderr and exits 1.
Two tests guard the help text and the completion spec so the next removal
cannot leave a phantom entry behind.

Verification: cargo test --lib, cargo clippy --lib --all-features -D warnings
clean, cargo fmt --check clean, scripts/loc-gate.sh OK.

Fixes #1604
Fixes #1602

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@yvgude
yvgude force-pushed the fix/issue-batch-1582-1599 branch from dba89bc to 99a35fc Compare August 29, 2026 08:54
@yvgude yvgude changed the title fix: OpenCode config safety (#1585,#1586), policy-aware drift (#1596,#1598,#1599), verbatim turn budget (#1582) fix: OpenCode config safety (#1585,#1586), policy-aware drift (#1596,#1598,#1599), verbatim turn budget (#1582), config/CLI/dispatch honesty (#1605,#1604,#1602) Aug 29, 2026
…ndows

The #1585 resolver had a `#[cfg(windows)]` branch returning `%APPDATA%\opencode`,
which ignores the `home` argument it is given. Two consequences, one of them
visible in CI on windows-latest:

1. Every caller that passes a synthetic home — the five
   `hooks::agents::opencode::dedicated_tests` — wrote to the runner's real
   profile instead, so the tests shared one file and contaminated each other
   (`unregister_drops_empty_instructions_key` read the config the
   `register_preserves_user_instructions` case had just written).
2. In production it split OpenCode's config away from everything else lean-ctx
   writes for that agent. The rules file, skills, detection, doctor wiring and
   uninstall all address `home/.config/opencode` on every platform — so on
   Windows the MCP entry would land in a directory the uninstaller never visits
   and the rules writer never fills.

OpenCode itself uses the XDG layout on Windows, so `home/.config/opencode` is
also the correct answer, not merely the consistent one. `config_dir` now
returns it unconditionally, and
`config_dir_stays_under_the_given_home_on_every_platform` fails the moment a
platform branch stops honouring its argument again.

Verification: cargo test --lib (10513 passed, 0 failed), cargo clippy --lib
--all-features -D warnings clean, cargo fmt --check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@yvgude
yvgude merged commit ea0e528 into main Aug 29, 2026
29 checks passed
@yvgude
yvgude deleted the fix/issue-batch-1582-1599 branch August 29, 2026 10:31
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 29, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.