fix(cli): track provider source for TUI fallback

Adapt PR #3011 so unsupported interactive providers report their actual source and config-sourced unsupported providers fall back to DeepSeek without forwarding a stale keyring secret.

Co-authored-by: cyq1017 <61975706+cyq1017@users.noreply.github.com>
This commit is contained in:
Hunter B
2026-06-12 01:30:23 -07:00
parent 0d4f2b6e73
commit a46d9c012e
3 changed files with 277 additions and 34 deletions
+4
View File
@@ -42,6 +42,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Constitution trust wording (#2950/#3008).** The base prompt now explains
that "begins with an A" means a baseline of trust, not a literal output
formatting rule. Thanks @cyq1017 for the PR.
- **TUI provider-source recovery (#3007/#3011).** Unsupported interactive
providers now report whether the value came from `--provider`, environment,
or config. Config-sourced unsupported providers fall back to DeepSeek without
forwarding stale keyring secrets. Thanks @cyq1017 for the PR.
- **TUI mouse-report leak (#3063/#3067).** Strip raw SGR mouse coordinate
tails from the composer even when `use_mouse_capture` is false, covering
orphaned terminal reporting state after crashes or focus races.
+168 -29
View File
@@ -14,7 +14,8 @@ use codewhale_app_server::{
AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
};
use codewhale_config::{
CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,
CliRuntimeOverrides, ConfigStore, ProviderKind, ProviderSource, ResolvedRuntimeOptions,
RuntimeApiKeySource,
};
use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
use codewhale_mcp::{McpServerDefinition, run_stdio_server};
@@ -825,6 +826,16 @@ fn provider_is_supported_by_tui(provider: ProviderKind) -> bool {
)
}
fn supported_tui_providers_csv() -> String {
ProviderKind::ALL
.iter()
.copied()
.filter(|provider| provider_is_supported_by_tui(*provider))
.map(ProviderKind::as_str)
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
fn no_keyring_secrets() -> Secrets {
Secrets::new(std::sync::Arc::new(
@@ -1699,44 +1710,70 @@ fn build_tui_command(
}
cmd.args(passthrough);
let mut launch_provider_override = cli.provider.map(ProviderKind::from);
let mut keyring_bridge_provider = resolved_runtime.provider;
let mut keyring_bridge_api_key = resolved_runtime.api_key.as_ref();
let mut keyring_bridge_source = resolved_runtime.api_key_source;
if !provider_is_supported_by_tui(resolved_runtime.provider) {
let source_hint = if cli.provider.is_some() {
"set via --provider flag"
} else {
"resolved from config file or environment"
};
bail!(
"The interactive TUI does not support provider '{}' ({}).\n\
\n\
Supported TUI providers: deepseek, openai, ollama, openrouter, nvidia-nim, \n\
volcengine, siliconflow, moonshot, arcee, fireworks, novita, xiaomi-mimo,\n\
huggingface, sglang, vllm, atlascloud, wanjie-ark, together, openai-codex.\n\
\n\
To fix:\n\
- Set a supported provider in your config file (~/.codewhale/config.toml)\n\
under [providers.<id>] with an api_key, or\n\
- Pass --provider <supported-id> on the command line, or\n\
- Run `codewhale exec --provider <supported-id> \"your prompt\"` for a\n\
one-shot non-interactive session with this provider.",
resolved_runtime.provider.as_str(),
source_hint,
);
let supported = supported_tui_providers_csv();
match resolved_runtime.provider_source {
ProviderSource::Cli => {
bail!(
"The interactive TUI does not support provider '{}' from --provider.\n\
\n\
Supported TUI providers: {supported}.\n\
\n\
To fix: remove `--provider {}` or pass a supported provider. \
For this provider, use `codewhale exec --provider {} \"your prompt\"`.",
resolved_runtime.provider.as_str(),
resolved_runtime.provider.as_str(),
resolved_runtime.provider.as_str(),
);
}
ProviderSource::Env(var) => {
bail!(
"The interactive TUI does not support provider '{}' from {var}.\n\
\n\
Supported TUI providers: {supported}.\n\
\n\
To fix: unset {var} or set it to a supported provider. \
For this provider, use `codewhale exec --provider {} \"your prompt\"`.",
resolved_runtime.provider.as_str(),
resolved_runtime.provider.as_str(),
);
}
ProviderSource::Config => {
let config_hint = cli
.config
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "~/.codewhale/config.toml".to_string());
eprintln!(
"Warning: provider '{}' from config is not supported by the interactive TUI; \
launching with deepseek instead. Edit {config_hint} and set \
provider = \"deepseek\", or pass --provider <supported-id>.",
resolved_runtime.provider.as_str(),
);
launch_provider_override = Some(ProviderKind::Deepseek);
keyring_bridge_provider = ProviderKind::Deepseek;
keyring_bridge_api_key = None;
keyring_bridge_source = None;
}
}
}
if let Some(provider) = cli.provider {
let provider: ProviderKind = provider.into();
if let Some(provider) = launch_provider_override {
cmd.env("DEEPSEEK_PROVIDER", provider.as_str());
}
if matches!(
resolved_runtime.api_key_source,
Some(RuntimeApiKeySource::Keyring)
) && let Some(api_key) = resolved_runtime.api_key.as_ref()
if matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring))
&& let Some(api_key) = keyring_bridge_api_key
{
// TUI reloads auth_mode from config/profile, but it does not re-query the
// platform keyring on normal startup. Bridge only the recovered secret;
// replaying auth_mode here would turn it back into a profile override.
cmd.env("DEEPSEEK_API_KEY", api_key);
for var in provider_env_vars(resolved_runtime.provider) {
for var in provider_env_vars(keyring_bridge_provider) {
if *var != "DEEPSEEK_API_KEY" {
cmd.env(var, api_key);
}
@@ -1985,6 +2022,40 @@ mod tests {
}
}
fn install_fake_tui_binary() -> (tempfile::TempDir, ScopedEnvVar) {
let dir = tempfile::TempDir::new().expect("tempdir");
let custom = dir
.path()
.join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
std::fs::write(&custom, b"").unwrap();
let custom_str = custom.to_string_lossy().into_owned();
let bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
(dir, bin)
}
fn resolved_runtime_for_test(
provider: ProviderKind,
provider_source: ProviderSource,
) -> ResolvedRuntimeOptions {
ResolvedRuntimeOptions {
provider,
provider_source,
model: "test-model".to_string(),
api_key: None,
api_key_source: None,
base_url: "http://localhost:8000/v1".to_string(),
auth_mode: None,
insecure_skip_tls_verify: false,
output_mode: None,
log_level: None,
telemetry: false,
approval_policy: None,
sandbox_mode: None,
yolo: None,
http_headers: std::collections::BTreeMap::new(),
}
}
#[test]
fn clap_command_definition_is_consistent() {
Cli::command().debug_assert();
@@ -3112,6 +3183,7 @@ mod tests {
]);
let resolved = ResolvedRuntimeOptions {
provider: ProviderKind::Openai,
provider_source: ProviderSource::Cli,
model: "glm-5".to_string(),
api_key: Some("resolved-openai-key".to_string()),
api_key_source: Some(RuntimeApiKeySource::Keyring),
@@ -3170,6 +3242,7 @@ mod tests {
let cli = parse_ok(&["codewhale", "doctor"]);
let resolved = ResolvedRuntimeOptions {
provider: ProviderKind::OpenaiCodex,
provider_source: ProviderSource::Config,
model: "gpt-5.5".to_string(),
api_key: None,
api_key_source: None,
@@ -3209,6 +3282,7 @@ mod tests {
let cli = parse_ok(&["codewhale", "--provider", "openai-codex", "doctor"]);
let resolved = ResolvedRuntimeOptions {
provider: ProviderKind::OpenaiCodex,
provider_source: ProviderSource::Cli,
model: "gpt-5.5".to_string(),
api_key: None,
api_key_source: None,
@@ -3232,6 +3306,66 @@ mod tests {
);
}
#[test]
fn build_tui_command_rejects_unsupported_cli_provider_with_flag_hint() {
let _lock = env_lock();
let (_dir, _bin) = install_fake_tui_binary();
let cli = parse_ok(&["codewhale", "doctor"]);
let resolved = resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Cli);
let err = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
.expect_err("unsupported provider should fail");
let msg = err.to_string();
assert!(msg.contains("from --provider"), "{msg}");
assert!(msg.contains("remove `--provider anthropic`"), "{msg}");
assert!(msg.contains("Supported TUI providers:"), "{msg}");
}
#[test]
fn build_tui_command_rejects_unsupported_env_provider_with_env_hint() {
let _lock = env_lock();
let (_dir, _bin) = install_fake_tui_binary();
let cli = parse_ok(&["codewhale", "doctor"]);
let resolved = resolved_runtime_for_test(
ProviderKind::Anthropic,
ProviderSource::Env("DEEPSEEK_PROVIDER"),
);
let err = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
.expect_err("unsupported provider should fail");
let msg = err.to_string();
assert!(msg.contains("from DEEPSEEK_PROVIDER"), "{msg}");
assert!(msg.contains("unset DEEPSEEK_PROVIDER"), "{msg}");
assert!(msg.contains("Supported TUI providers:"), "{msg}");
}
#[test]
fn build_tui_command_config_fallback_does_not_forward_stale_keyring_secret() {
let _lock = env_lock();
let (_dir, _bin) = install_fake_tui_binary();
let cli = parse_ok(&["codewhale", "doctor"]);
let mut resolved =
resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Config);
resolved.api_key = Some("anthropic-keyring-secret".to_string());
resolved.api_key_source = Some(RuntimeApiKeySource::Keyring);
let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
.expect("config-sourced unsupported provider should fall back");
assert_eq!(
command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
Some("deepseek")
);
assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY"), None);
assert_eq!(command_env(&cmd, "ANTHROPIC_API_KEY"), None);
assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE"), None);
}
#[test]
fn build_tui_command_does_not_export_default_runtime_overrides_for_profiles() {
let _lock = env_lock();
@@ -3248,6 +3382,7 @@ mod tests {
resolved_headers.insert("X-From-Base".to_string(), "base".to_string());
let resolved = ResolvedRuntimeOptions {
provider: ProviderKind::Deepseek,
provider_source: ProviderSource::Config,
model: "deepseek-v4-pro".to_string(),
api_key: Some("config-file-key".to_string()),
api_key_source: Some(RuntimeApiKeySource::ConfigFile),
@@ -3304,6 +3439,7 @@ mod tests {
]);
let resolved = ResolvedRuntimeOptions {
provider: ProviderKind::Moonshot,
provider_source: ProviderSource::Cli,
model: "kimi-k2.6".to_string(),
api_key: Some("resolved-kimi-key".to_string()),
api_key_source: Some(RuntimeApiKeySource::Keyring),
@@ -3369,6 +3505,7 @@ mod tests {
]);
let resolved = ResolvedRuntimeOptions {
provider: ProviderKind::Volcengine,
provider_source: ProviderSource::Cli,
model: "DeepSeek-V4-Pro".to_string(),
api_key: Some("resolved-ark-key".to_string()),
api_key_source: Some(RuntimeApiKeySource::Keyring),
@@ -3435,6 +3572,7 @@ mod tests {
]);
let resolved = ResolvedRuntimeOptions {
provider: ProviderKind::Openai,
provider_source: ProviderSource::Cli,
model: "glm-5".to_string(),
api_key: None,
api_key_source: None,
@@ -3531,6 +3669,7 @@ mod tests {
]);
let resolved = ResolvedRuntimeOptions {
provider,
provider_source: ProviderSource::Cli,
model: "test-model".to_string(),
api_key: Some("test-key".to_string()),
api_key_source: Some(RuntimeApiKeySource::Keyring),
+105 -5
View File
@@ -2001,7 +2001,16 @@ impl ConfigToml {
secrets: &Secrets,
) -> ResolvedRuntimeOptions {
let env = EnvRuntimeOverrides::load();
let provider = cli.provider.or(env.provider).unwrap_or(self.provider);
let (provider, provider_source) = if let Some(provider) = cli.provider {
(provider, ProviderSource::Cli)
} else if let Some(provider) = env.provider {
(
provider,
ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
)
} else {
(self.provider, ProviderSource::Config)
};
let mut provider_cfg = self.providers.for_provider(provider).clone();
if provider == ProviderKind::SiliconflowCN {
@@ -2196,6 +2205,7 @@ impl ConfigToml {
ResolvedRuntimeOptions {
provider,
provider_source,
model,
api_key,
api_key_source,
@@ -2835,9 +2845,17 @@ impl RuntimeApiKeySource {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderSource {
Cli,
Env(&'static str),
Config,
}
#[derive(Debug, Clone)]
pub struct ResolvedRuntimeOptions {
pub provider: ProviderKind,
pub provider_source: ProviderSource,
pub model: String,
pub api_key: Option<String>,
pub api_key_source: Option<RuntimeApiKeySource>,
@@ -3263,6 +3281,7 @@ fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
#[derive(Debug, Clone, Default)]
struct EnvRuntimeOverrides {
provider: Option<ProviderKind>,
provider_source: Option<&'static str>,
model: Option<String>,
volcengine_model: Option<String>,
wanjie_ark_model: Option<String>,
@@ -3310,11 +3329,10 @@ struct EnvRuntimeOverrides {
impl EnvRuntimeOverrides {
fn load() -> Self {
let (provider, provider_source) = Self::load_provider();
Self {
provider: std::env::var("CODEWHALE_PROVIDER")
.or_else(|_| std::env::var("DEEPSEEK_PROVIDER"))
.ok()
.and_then(|v| ProviderKind::parse(&v)),
provider,
provider_source,
model: std::env::var("CODEWHALE_MODEL")
.or_else(|_| std::env::var("DEEPSEEK_MODEL"))
.or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
@@ -3478,6 +3496,20 @@ impl EnvRuntimeOverrides {
}
}
fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
let parsed = ProviderKind::parse(&value);
return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
}
if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
let parsed = ProviderKind::parse(&value);
return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
}
(None, None)
}
fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
// Defaults belong in the resolver's final fallback so config-file
// values (`providers.<name>.base_url`) still win when env is unset.
@@ -5578,6 +5610,10 @@ mode = "token-plan-usa"
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::Moonshot);
assert_eq!(
resolved.provider_source,
ProviderSource::Env("CODEWHALE_PROVIDER")
);
assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key"));
@@ -5604,6 +5640,70 @@ mode = "token-plan-usa"
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::Moonshot);
assert_eq!(
resolved.provider_source,
ProviderSource::Env("CODEWHALE_PROVIDER")
);
}
#[test]
fn legacy_deepseek_provider_env_records_provider_source() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
// Safety: test-only env mutation guarded by env_lock().
unsafe {
env::set_var("DEEPSEEK_PROVIDER", "openrouter");
}
let config = ConfigToml {
provider: ProviderKind::Deepseek,
..ConfigToml::default()
};
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::Openrouter);
assert_eq!(
resolved.provider_source,
ProviderSource::Env("DEEPSEEK_PROVIDER")
);
}
#[test]
fn cli_provider_records_provider_source() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
// Safety: test-only env mutation guarded by env_lock().
unsafe {
env::set_var("CODEWHALE_PROVIDER", "moonshot");
}
let cli = CliRuntimeOverrides {
provider: Some(ProviderKind::Openai),
..CliRuntimeOverrides::default()
};
let config = ConfigToml {
provider: ProviderKind::Deepseek,
..ConfigToml::default()
};
let resolved = config.resolve_runtime_options(&cli);
assert_eq!(resolved.provider, ProviderKind::Openai);
assert_eq!(resolved.provider_source, ProviderSource::Cli);
}
#[test]
fn config_provider_records_provider_source() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let config = ConfigToml {
provider: ProviderKind::Moonshot,
..ConfigToml::default()
};
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::Moonshot);
assert_eq!(resolved.provider_source, ProviderSource::Config);
}
/// `CODEWHALE_MODEL` is the user-facing env alias for picking a model