diff --git a/.catalyst-code/plugins/plugin-umans/plugin.json b/.catalyst-code/plugins/plugin-umans/plugin.json new file mode 100644 index 0000000..dbca4fd --- /dev/null +++ b/.catalyst-code/plugins/plugin-umans/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "plugin-umans", + "version": "0.1.0", + "description": "Umans Code API provider — key-sharing OpenAI-compatible endpoint. The core keeps umans built-in for models, tools (writing), and TUI; this plugin marks it as a user-discoverable provider so new installs can add it from the catalog." +} \ No newline at end of file diff --git a/core/src/config.rs b/core/src/config.rs index 73c8772..47e463a 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -1147,12 +1147,6 @@ pub fn auto_login_env_presets(_cfg: &mut Config) -> Vec { Vec::new() } -/// Built-in presets no longer carry OAuth credentials (subscription login is -/// plugin-only). Always returns false; kept so call sites compile during the -/// migration away from vendor OAuth. -pub fn preset_has_oauth_creds(_p: &ProviderPreset) -> bool { - false -} impl Config { pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> { diff --git a/core/src/main.rs b/core/src/main.rs index 1ec9d65..a4c8dd6 100644 --- a/core/src/main.rs +++ b/core/src/main.rs @@ -6160,17 +6160,16 @@ async fn run_turn( // password is fed via `sudo -S` on stdin; on decline // (Esc) the agent is told the user declined. // - // Approval::Never optimization: when approval is Never - // and the user has NOPASSWD sudo (or cached creds), we - // skip the prompt entirely and run with `sudo -n`. Only - // if a password is actually needed do we prompt — so - // users with NOPASSWD never see a sudo flyout. + // In Approval::Never, probe sudo non-interactively and + // prompt only when it explicitly asks for a password. + // NOPASSWD/cached credentials run immediately; other + // failures are surfaced by `sudo -n` without a flyout. if tools::command_uses_sudo(cmd) { - let needs_prompt = match cfg.approval { - crate::config::Approval::Never => { - tools::sudo_needs_password(&cfg).await - } - _ => true, + let needs_prompt = if matches!(cfg.approval, Approval::Never) { + let sudo_preflight = tools::sudo_preflight(&cfg).await; + tools::sudo_should_prompt(&cfg.approval, sudo_preflight) + } else { + true }; if needs_prompt { match request_sudo(st, cmd, &cancel).await { @@ -6912,9 +6911,11 @@ async fn handle_user_bash(st: &Arc, command: String, exclude_from_context let cancel = CancellationToken::new(); let outcome = if tools::command_uses_sudo(&command) { - let needs_prompt = match cfg.approval { - crate::config::Approval::Never => tools::sudo_needs_password(&cfg).await, - _ => true, + let needs_prompt = if matches!(cfg.approval, Approval::Never) { + let sudo_preflight = tools::sudo_preflight(&cfg).await; + tools::sudo_should_prompt(&cfg.approval, sudo_preflight) + } else { + true }; if needs_prompt { match request_sudo(st, &command, &cancel).await { diff --git a/core/src/provider.rs b/core/src/provider.rs index 6d3d83c..fb37aa6 100644 --- a/core/src/provider.rs +++ b/core/src/provider.rs @@ -2579,43 +2579,6 @@ pub fn is_opencode_go(base_url: &str) -> bool { host == "opencode.ai" && base_url.to_ascii_lowercase().contains("/zen/go/") } -/// True for GitHub Copilot's OpenAI-compatible chat endpoint. -pub fn is_github_copilot_endpoint(base_url: &str) -> bool { - endpoint_host(base_url) == "api.githubcopilot.com" -} - -/// True for Kimi Coding's OpenAI-compatible subscription endpoint. -pub fn is_kimi_coding_endpoint(base_url: &str) -> bool { - endpoint_host(base_url) == "api.kimi.com" && base_url.to_ascii_lowercase().contains("/coding/") -} - -/// True for Kilo Code's OpenRouter-compatible gateway endpoint. -pub fn is_kilocode_endpoint(base_url: &str) -> bool { - endpoint_host(base_url) == "api.kilo.ai" -} - -pub fn is_cline_endpoint(base_url: &str) -> bool { - endpoint_host(base_url) == "api.cline.bot" -} - -/// True when `base_url` points at Anthropic's API (`api.anthropic.com`). -/// The Claude subscription OAuth token must ONLY be sent there — never to a -/// third-party Anthropic-compatible endpoint (a proxy, a local server) — so -/// `enrich_oauth` resolves it only when this is true (not on `kind` alone, -/// which would leak the token to any `kind:"anthropic"` provider). -pub fn is_anthropic_endpoint(base_url: &str) -> bool { - endpoint_host(base_url) == "api.anthropic.com" -} - -pub fn is_kimchi_endpoint(base_url: &str) -> bool { - let h = endpoint_host(base_url); - h == "llm.kimchi.dev" || h.ends_with(".kimchi.dev") -} - -pub fn is_codebuddy_endpoint(base_url: &str) -> bool { - endpoint_host(base_url) == "copilot.tencent.com" -} - pub fn is_iflow_endpoint(base_url: &str) -> bool { let h = endpoint_host(base_url); h == "apis.iflow.cn" || h == "iflow.cn" || h.ends_with(".iflow.cn") @@ -3006,23 +2969,6 @@ pub fn is_xai_endpoint(base_url: &str) -> bool { host == "api.x.ai" || host == "x.ai" || host.ends_with(".x.ai") } -/// True when `base_url` points at Qwen Code's portal chat endpoint -/// (`portal.qwen.ai`). Used by `oauth::enrich_oauth` and presence checks so a -/// user-added provider at that host still picks up the Qwen OAuth token. -pub fn is_qwen_endpoint(base_url: &str) -> bool { - let host = base_url - .split("://") - .nth(1) - .unwrap_or(base_url) - .split(['/', '?']) - .next() - .unwrap_or("") - .split(':') - .next() - .unwrap_or("") - .to_ascii_lowercase(); - host == "portal.qwen.ai" || host.ends_with(".portal.qwen.ai") || host == "chat.qwen.ai" -} /// Sanitize orphaned tool_calls: ensure every tool_calls entry has a matching /// tool result message. Context compaction can drop tool results while keeping diff --git a/core/src/tools.rs b/core/src/tools.rs index 09984ca..76c0cfa 100644 --- a/core/src/tools.rs +++ b/core/src/tools.rs @@ -1843,23 +1843,54 @@ pub fn command_uses_sudo(command: &str) -> bool { SUDO_RE.is_match(command) } -/// Pre-check: does this system need a password for sudo? Runs `sudo -n true` -/// (non-interactive — never opens /dev/tty). Returns true if a password is -/// needed (NOPASSWD not configured + no cached credentials). Used by the -/// dispatch layer when approval is `Never` to decide whether to show the -/// sudo prompt: users with NOPASSWD sudo never see it. -pub async fn sudo_needs_password(cfg: &Config) -> bool { +/// Result of checking whether sudo can authenticate without user input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SudoPreflight { + /// Sudo is ready (NOPASSWD, a valid credential timestamp, or root). + NonInteractive, + /// Sudo explicitly reported that authentication requires a password. + PasswordRequired, + /// Sudo could not be checked or failed for a reason a password cannot fix. + Unavailable, +} + +/// Classify a `sudo -n true` result. Keep this deliberately narrow: in +/// permissive mode an absent sudo binary, a sudoers denial, or a broken policy +/// must produce a normal command failure, not a misleading password prompt. +fn classify_sudo_preflight(success: bool, stderr: &[u8]) -> SudoPreflight { + if success { + return SudoPreflight::NonInteractive; + } + + // The probe sets LC_ALL=C so these are stable sudo diagnostics. The + // additional variants cover older sudo/PAM combinations. + let stderr = String::from_utf8_lossy(stderr).to_ascii_lowercase(); + if stderr.contains("password is required") + || stderr.contains("password required") + || stderr.contains("no tty present and no askpass program specified") + || stderr.contains("a terminal is required to read the password") + { + SudoPreflight::PasswordRequired + } else { + SudoPreflight::Unavailable + } +} + +/// Check whether sudo can run without a password. The probe is always +/// non-interactive and can never open `/dev/tty`. +pub async fn sudo_preflight(cfg: &Config) -> SudoPreflight { // POSIX-only (never reached on Windows: command_uses_sudo is false there, // so the caller's `if tools::command_uses_sudo(cmd)` branch is skipped). if !shell_is_posix() { - return true; + return SudoPreflight::Unavailable; } - let mut cmd = tokio::process::Command::new("bash"); - cmd.arg("-c").arg("sudo -n true 2>/dev/null"); + let mut cmd = tokio::process::Command::new("sudo"); + cmd.args(["-n", "true"]); cmd.current_dir(&cfg.workspace); cmd.stdin(std::process::Stdio::null()); cmd.stdout(std::process::Stdio::null()); - cmd.stderr(std::process::Stdio::null()); + cmd.stderr(std::process::Stdio::piped()); + cmd.kill_on_drop(true); cmd.env_clear(); cmd.env( "PATH", @@ -1868,14 +1899,25 @@ pub async fn sudo_needs_password(cfg: &Config) -> bool { if let Ok(home) = std::env::var("HOME") { cmd.env("HOME", home); } - // `sudo -n true` exits 0 if NOPASSWD or cached; non-zero if a password - // is needed. A timeout guards against hangs. - match tokio::time::timeout(std::time::Duration::from_secs(5), cmd.status()).await { - Ok(Ok(status)) => !status.success(), - _ => true, // Timeout or spawn failure → assume password needed (safe). + cmd.env("LC_ALL", "C"); + cmd.env("LANG", "C"); + + // A timeout/spawn error is not evidence that the account has a password. + // In Never mode it therefore falls through to a non-interactive execution, + // which reports the real error without opening a UI prompt. + match tokio::time::timeout(std::time::Duration::from_secs(5), cmd.output()).await { + Ok(Ok(output)) => classify_sudo_preflight(output.status.success(), &output.stderr), + _ => SudoPreflight::Unavailable, } } +/// Sudo prompts are policy prompts outside permissive (`Never`) mode. In +/// permissive mode they are authentication-only and appear solely when sudo +/// explicitly says a password is required. +pub fn sudo_should_prompt(approval: &Approval, preflight: SudoPreflight) -> bool { + !matches!(approval, Approval::Never) || matches!(preflight, SudoPreflight::PasswordRequired) +} + /// How to handle sudo when the command invokes it. pub enum SudoAuth { /// No sudo auth available. If the command uses sudo, returns a clean error @@ -1885,7 +1927,8 @@ pub enum SudoAuth { Password(String), /// Run with `sudo -n` (non-interactive). Succeeds if NOPASSWD or cached /// credentials exist; fails cleanly (never opens /dev/tty) if a password - /// is needed. Used when approval is Never and NOPASSWD is confirmed. + /// is needed or sudo is unavailable. Used whenever approval is Never and + /// the preflight did not explicitly identify a password requirement. NonInteractive, } @@ -5406,6 +5449,47 @@ mod tests { assert!(!command_uses_sudo("")); } + #[test] + fn sudo_preflight_only_identifies_password_diagnostics() { + assert_eq!( + classify_sudo_preflight(true, b""), + SudoPreflight::NonInteractive + ); + assert_eq!( + classify_sudo_preflight(false, b"sudo: a password is required\n"), + SudoPreflight::PasswordRequired + ); + assert_eq!( + classify_sudo_preflight(false, b"sudo: user is not allowed to execute true\n"), + SudoPreflight::Unavailable + ); + assert_eq!( + classify_sudo_preflight(false, b"sudo: command not found\n"), + SudoPreflight::Unavailable + ); + } + + #[test] + fn sudo_prompt_respects_permission_mode_and_password_state() { + assert!(!sudo_should_prompt( + &Approval::Never, + SudoPreflight::NonInteractive + )); + assert!(!sudo_should_prompt( + &Approval::Never, + SudoPreflight::Unavailable + )); + assert!(sudo_should_prompt( + &Approval::Never, + SudoPreflight::PasswordRequired + )); + + for approval in [Approval::Destructive, Approval::Always] { + assert!(sudo_should_prompt(&approval, SudoPreflight::NonInteractive)); + assert!(sudo_should_prompt(&approval, SudoPreflight::Unavailable)); + } + } + #[test] fn shell_resolution_default_is_posix_on_unix() { // On a POSIX host with no CATALYST_CODE_SHELL override, the shell is