Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .catalyst-code/plugins/plugin-umans/plugin.json
Original file line number Diff line number Diff line change
@@ -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."
}
6 changes: 0 additions & 6 deletions core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1147,12 +1147,6 @@ pub fn auto_login_env_presets(_cfg: &mut Config) -> Vec<String> {
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> {
Expand Down
27 changes: 14 additions & 13 deletions core/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -6912,9 +6911,11 @@ async fn handle_user_bash(st: &Arc<State>, 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 {
Expand Down
54 changes: 0 additions & 54 deletions core/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
116 changes: 100 additions & 16 deletions core/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand All @@ -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,
}

Expand Down Expand Up @@ -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
Expand Down
Loading