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
Conversation
…, 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
force-pushed
the
fix/issue-batch-1582-1599
branch
from
August 29, 2026 08:54
dba89bc to
99a35fc
Compare
This was referenced 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_modeandregister_opencode_instructions) started from an empty object wheneverparse_jsoncfailed, 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_jsoncalso tolerates a single leading UTF-8 BOM, which Windows editors emit routinely andserde_jsonrejects — that rejection was one of the ways a healthy config got read as "corrupt".#1585 — OpenCode accepts both
opencode.jsonandopencode.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_configresolver (exactly one exists → that file; both →.json, flagged ambiguous; neither →.jsonfor 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"orrules_scope = "project"made its absence deliberate. Claude Code's dedicated check already reasoned this way;check_rules_file_for_policygives Cline, Cline CLI, Roo, Windsurf and the rest the same rule.#1598 — Windows
setup --fixloop.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 asetup --fixloop that rewrote the file to a byte-identical result.same_windows_pathcompares the two spellings; anything not drive-qualified in one of those two forms returnsfalse, so ordinary Unix paths are never conflated.#1599 —
rules_injection = "off"was only half honoured.That setting is an opt-out from lean-ctx steering the agent. The initialize
instructionsfield 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=trueis 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=trueormode="raw", onctx_readandctx_shell) now use a separateturn_fresh_limit_verbatim— default 32768, envLEAN_CTX_TURN_FRESH_LIMIT_VERBATIM,0= unlimited, and never below the ordinary limit so raisingturn_fresh_limitcan't make a raw read deliver less than a compressed one. Everyday modes keep the 4096-token backstop: exemptingfull,lines:,anchoredand friends would switch the backstop off for most traffic, and their truncation banner already points at a workinglines=recovery.Measured, not asserted
235 KB / 6000-line markdown file, driven through the stdio server:
{mode:"raw", raw:true}{mode:"raw"}{mode:"full"}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 --lib→ 10508 passed, 0 failed, 22 ignoredcargo clippy --lib --all-features→ cleanscripts/preflight.sh fast→ 7/8. The one failure isgit diff --checkevaluated over the divergent merge-base (437132fa, 3466 pre-existing files from the other lineage);git diff --checkon this branch's own changes is empty.New tests: 6 in
hooks::agents::opencode::config_preservation_tests, 9 indoctor::integrations::wiring::tests, 5 incore::opencode_config, 3 incore::config::logic::verbatim_budget_tests, 3 inserver::call_tool::pipeline::savings_tests, 2 incore::jsonc::tests.#1605 —
config set llm.api_keyreported a save it discardedThe
[llm]schema block was hand-written with literal defaults instead of being derived fromcfg.llm, and had drifted three ways: anapi_keykey no struct field backs (soset_by_keywrote it into the TOML table and theConfiground-trip silently dropped it — after the CLI printed "Updated"), amodeldefault ofllama3.2against the struct'sqwen2.5-coder:1.5b, and a missingbase_url. Defaults now come fromcfg.llm.*like the neighbouring[decision_loop]block,api_keyis gone (config set llm.api_key …fails withUnknown config key; the section description namesOPENROUTER_API_KEY/ANTHROPIC_API_KEY), andllm_schema_keys_match_the_llm_config_structguards the class so the next drift fails in CI instead of eating a secret.config-keys.mdregenerated.#1604 —
ctx_callanswered "path is required" about a parameter the caller suppliedThe envelope parser accepted the inner arguments only under
arguments(plus the #658 guard that turns anargs/params/parameters/argwrapper into a "did you meanarguments" 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: flattenedctx_glob→ "pattern is required", flattenedctx_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.nameis the only keyctx_callreserves, 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 existingUnknown tool: …+ nearest-match path (#712), which is what the reporter asked for.ctx_call_forwards_flattened_inner_argumentspins all four shapes; it drivesctx_readdeliberately, 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].#1602 —
lean-ctx watchwas still advertised after its removalThe TUI went with the dead-module cleanup in 3.9.20 (de96cc5), but
--help, the completion spec andwatch --helpall still described it as a working command, while barewatchexited 1 with "The live TUI dashboard has been removed." and named no replacement. All four surfaces now carry the same notice and point atlean-ctx dashboard(web UI, same event stream),lean-ctx cep(metrics as text) andlean-ctx index watch(the index watcher).watch --help→ stdout, exit 0; barewatch→ stderr, exit 1. The completion entry is hidden rather than deleted, with a "Removed — seelean-ctx dashboard" description. Guarded byfull_help_does_not_advertise_the_removed_tui_dashboardandremoved_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