071d23a4b7
Reproduction (from the user who filed #593, also the reporter of #586): 1. At any prior point, the user runs `deepseek auth set --provider deepseek`, which writes to the OS keyring under the `deepseek` slot. 2. The key is later rotated, the prior install is replaced, or the user moves to a different account. 3. The user opens the TUI, gets the in-TUI onboarding screen, and pastes their fresh API key. 4. `submit_api_key` → `save_api_key` writes only to `~/.deepseek/config.toml`. 5. At request time, `Secrets::resolve` follows the documented `keyring → env → config-file` precedence, and the **stale** keyring entry shadows the fresh config.toml value. 6. API call goes out with the dead key, gets a 401, the TUI shows "no response" with no obvious diagnostic. The fix ======= `save_api_key` now writes to **both** layers when a keyring backend is reachable: * The config file remains the durable, inspectable record of the active key (works in npm installs, IDE terminals, headless CI — everywhere). v0.8.8 made this the canonical location for a reason. * The OS keyring entry is rewritten on every onboarding submit so a stale credential from a prior install is overwritten in place. `SavedCredential` gains a new `KeyringAndConfigFile { backend, path }` variant; the existing `ConfigFile(PathBuf)` variant remains the fallback when no keyring backend is reachable (or under `cfg(test)`, so the unit suite never pollutes the host keyring). The onboarding toast naturally reports the actual outcome via `SavedCredential::describe`, which now reads `OS keyring (system keyring) and ~/.deepseek/config.toml` for the common case. `save_api_key_for` (the multi-provider entry point) is updated to extract the path from either variant, so non-DeepSeek providers (OpenRouter / Novita / Fireworks / NIM / SGLang) continue writing provider-table entries to config.toml only, with no behavior change. `deepseek doctor` warning ========================= `run_doctor` now compares the keyring's `deepseek` slot against the config file's `api_key` slot. When both are present and differ, the report surfaces the discrepancy with copy-paste remediation — `deepseek auth set --provider deepseek` rewrites both layers in one shot, and the in-TUI onboarding now does the same. The check skips keyring probes for other providers because they don't write to the keyring today; probing absent slots only triggers macOS Always-Allow prompts for nothing. Why dual-write rather than keyring-only ======================================= A previous attempt (`4e360274`, never merged to main) swapped the write path to keyring-only. That hides the key from anyone who expected to see it under `~/.deepseek/config.toml` and breaks the "deepseek-tui works in every folder, in npm installs, in IDE terminals" promise of v0.8.8. Dual-write keeps the inspectable copy and adds the layered override that defeats stale-shadow without changing the visible mental model. Tests ===== * `saved_credential_describe_lists_both_targets_for_keyring_and_config` pins the toast text shape so the user sees both targets after onboarding. * The existing `save_api_key_writes_config_file_under_cfg_test` and `test_save_api_key_doesnt_match_similar_keys` continue to pass — under `cfg(test)` the keyring path is gated out, so the config-only outcome remains the test-time contract. Verification ============ * `cargo fmt --all -- --check` clean. * `cargo clippy -p deepseek-tui --bin deepseek-tui --all-features --locked -- -D warnings` clean. * `cargo test -p deepseek-tui --bin deepseek-tui --locked` → 2029 passed, 2 ignored. Closes #593. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>