diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index a31d09a7d..eb1259220 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -221,6 +221,7 @@ struct TaskState { /// A running agent and its session. #[derive(Clone)] struct Live { + sign_in_message: String, peer: acp::Conn, session: String, capabilities: AgentCapabilities, @@ -1301,11 +1302,7 @@ impl Task { match e { // Signed out, or the account was refused (Claude Code: "API Error: 403"). RpcError::Server { message, .. } if message.to_lowercase().contains("auth") => { - let provider = self.state.lock().task.provider.clone(); - Failure::from(match self.shared.spec(&provider) { - Some(spec) => format!("{} needs you to sign in. {}", spec.name, spec.login_hint), - None => message, - }) + Failure::from(self.live.lock().as_ref().map(|live| live.sign_in_message.clone()).unwrap_or(message)) } e => Failure::from(self.describe(e)), } @@ -1323,6 +1320,11 @@ impl Task { (s.task.provider.clone(), s.task.cwd.0.clone(), s.session_id.clone()) }; let spec = self.shared.spec(&provider_id).ok_or_else(|| format!("unknown agent {provider_id}"))?; + let launch_cwd = cwd.clone(); + let spec = tokio::task::spawn_blocking(move || spec.resolve_for_launch(&launch_cwd)) + .await + .map_err(|e| e.to_string())?; + let sign_in_message = spec.sign_in_message(); self.stderr.lock().clear(); self.config_sent.lock().clear(); let weak = Arc::downgrade(self); @@ -1368,7 +1370,7 @@ impl Task { .await .map_err(|e| match e { RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => { - format!("{} needs you to sign in. {}", spec.name, spec.login_hint) + sign_in_message.clone() } e => self.describe(e), })?; @@ -1440,7 +1442,7 @@ impl Task { } self.persist(); self.emit_task(); - let live = Live { peer, session: session_id, capabilities, embedded, resumable: can_load }; + let live = Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load }; *self.live.lock() = Some(live.clone()); Ok(live) } diff --git a/crates/ide-agent/src/provider.rs b/crates/ide-agent/src/provider.rs index 410bb89eb..14d388d58 100644 --- a/crates/ide-agent/src/provider.rs +++ b/crates/ide-agent/src/provider.rs @@ -81,6 +81,64 @@ fn builtins() -> Vec { .collect() } +impl ProviderSpec { + pub(crate) fn login_hint(&self, program: &Path) -> String { + if !program.is_absolute() { + return self.login_hint.clone(); + } + let search = ide_core::process::host_var_os("PATH").unwrap_or_default(); + let builtin = builtin_spec(&self.id); + let on_path = builtin.as_ref().and_then(|b| ide_core::process::find_executable(&b.command, &search)); + render_login_hint(self, program, on_path.as_deref(), cfg!(windows)) + } + + pub(crate) fn sign_in_message(&self) -> String { + let name = &self.name; + let hint = self.login_hint(Path::new(&self.command)); + format!("{name} needs you to sign in. {hint}") + } + + /// Resolve once before connecting, so authentication guidance names that same install. + pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self { + if self.driver != Driver::Acp { + self.command = crate::native::newest_install(&self.command); + } + let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env); + self.command = + if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program } + .to_string_lossy() + .into_owned(); + self + } +} + +fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String { + let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() }; + let prefix = format!("Run `{}", builtin.command); + let Some(rest) = spec.login_hint.strip_prefix(&prefix).filter(|rest| rest.starts_with([' ', '`'])) else { + return spec.login_hint.clone(); + }; + let same_path = |a: &Path, b: &Path| { + a == b || dunce::canonicalize(a).ok().zip(dunce::canonicalize(b).ok()).is_some_and(|(a, b)| a == b) + }; + if on_path.is_some_and(|path| same_path(path, program)) { + return spec.login_hint.clone(); + } + let path = program.to_string_lossy(); + let quoted = if windows { + format!("& '{}'", path.replace('\'', "''")) + } else { + format!("'{}'", path.replace('\'', "'\\''")) + }; + let (args, suffix) = rest.split_once('`').expect("built-in login hints delimit their command"); + let command = format!("{quoted}{args}"); + match suffix { + " in a terminal." => format!("Run `{command}` in a terminal."), + " in a terminal and sign in." => format!("Run `{command}` in a terminal and sign in."), + _ => spec.login_hint.clone(), + } +} + /// Whether `id` names a built-in agent that needs no registry install (native drivers /// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and /// updated from the ACP Registry into `/agents/.toml`. @@ -119,7 +177,7 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider { args: spec.args.clone(), default_program: builtin.as_ref().map(|b| b.command.clone()), default_args: builtin.as_ref().map(|b| b.args.clone()).unwrap_or_default(), - login_hint: Some(spec.login_hint.clone()), + login_hint: Some(spec.login_hint(&ide_jsonrpc::resolve_spawn_program(&spec.command, &spec.env))), command_overridden: builtin.as_ref().is_some_and(|b| b.command != spec.command), command_editable: reason.is_none(), command_read_only_reason: reason, @@ -312,6 +370,81 @@ fn is_executable(p: &Path) -> bool { mod tests { use super::*; + #[test] + fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() { + for spec in builtins() { + let separate = matches!(spec.id.as_str(), "claude-acp" | "codex-acp" | "vibe"); + for windows in [false, true] { + let program = + if windows { Path::new(r"C:\Agent Tools\renamed.cmd") } else { Path::new("/agent tools/renamed") }; + let hint = render_login_hint(&spec, program, None, windows); + if separate { + assert_eq!(hint, spec.login_hint, "{}", spec.id); + } else { + let prefix = + if windows { "Run `& 'C:\\Agent Tools\\renamed.cmd'" } else { "Run `'/agent tools/renamed'" }; + assert!(hint.starts_with(prefix), "{}: {hint}", spec.id); + assert_eq!( + render_login_hint(&spec, program, Some(program), windows), + spec.login_hint, + "a PATH-resolved command stays bare: {}", + spec.id + ); + assert_eq!(render_login_hint(&spec, program, Some(Path::new("/other/install")), windows), hint); + } + } + } + } + + #[test] + fn login_commands_quote_shell_metacharacters_and_keep_arguments() { + let cursor = builtin_spec("cursor").unwrap(); + assert_eq!( + render_login_hint(&cursor, Path::new("/agent's tools/$cli"), None, false), + "Run `'/agent'\\''s tools/$cli' login` in a terminal." + ); + assert_eq!( + render_login_hint(&cursor, Path::new(r"C:\agent's tools\$cli.cmd"), None, true), + r"Run `& 'C:\agent''s tools\$cli.cmd' login` in a terminal." + ); + let opencode = builtin_spec("opencode").unwrap(); + assert_eq!( + render_login_hint(&opencode, Path::new("/registry/opencode"), None, false), + "Run `'/registry/opencode' auth login` in a terminal." + ); + let mut custom = cursor; + custom.id = "custom".into(); + custom.login_hint = "Sign in with the agent's own command line first.".into(); + assert_eq!(render_login_hint(&custom, Path::new("/custom"), None, false), custom.login_hint); + } + + #[test] + fn launch_resolution_uses_the_child_path_and_absolutizes_relative_overrides() { + let dir = tempfile::tempdir().unwrap(); + let mut cursor = builtin_spec("cursor").unwrap(); + let command = if cfg!(windows) { "cursor-agent.cmd" } else { "cursor-agent" }; + let program = dir.path().join(command); + std::fs::write(&program, "").unwrap(); + cursor.env.push(("PATH".into(), dir.path().to_string_lossy().into_owned())); + let resolved = cursor.clone().resolve_for_launch(dir.path()); + assert_eq!(Path::new(&resolved.command), program); + assert!(resolved.sign_in_message().contains(&*program.to_string_lossy())); + cursor.command = format!("tools/{command}"); + let resolved = cursor.resolve_for_launch(dir.path()); + assert_eq!(Path::new(&resolved.command), dir.path().join("tools").join(command)); + } + + #[cfg(unix)] + #[test] + fn registry_login_hint_names_the_installed_executable() { + let mut spec = builtin_spec("cursor").unwrap(); + spec.command = "/registry path/dist-package/cursor-agent".into(); + assert_eq!( + describe(&spec).login_hint.as_deref(), + Some("Run `'/registry path/dist-package/cursor-agent' login` in a terminal.") + ); + } + #[test] fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index ca9f69566..4f9116546 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -11,6 +11,7 @@ struct FakeAgent { calls: Mutex>, /// Without `loadSession` (a session lives only as long as its process). no_load: bool, + auth_on_new: bool, /// Config options `session/new` reports after the model one (per-model efforts, say). extra_options: Mutex>, } @@ -87,6 +88,11 @@ impl Handler for FakeAgent { fn on_request(&self, method: &str, params: Value) -> Result { self.calls.lock().push(method.to_owned()); + if (method == "session/new" && self.auth_on_new) + || (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth"))) + { + return Err((codes::INTERNAL_ERROR, "Authentication required".into())); + } let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned(); match method { "initialize" => { @@ -250,6 +256,49 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc) -> AgentMan ) } +#[tokio::test(flavor = "multi_thread")] +async fn authentication_failures_name_the_session_executable_at_start_and_during_prompts() { + for auth_on_new in [true, false] { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent { auth_on_new, ..Default::default() }); + let mgr = manager(tmp.path(), agent); + let program = tmp.path().join("Agent Tools").join("renamed-cursor.cmd"); + mgr.shared.providers.write().iter_mut().find(|s| s.id == "cursor").unwrap().command = + program.to_string_lossy().into_owned(); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("again"), + provider: "cursor".into(), + config: vec![], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + if !auth_on_new { + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + // A settings change must not redirect guidance for the already running process. + mgr.shared.providers.write().iter_mut().find(|s| s.id == "cursor").unwrap().command = + "different-cursor".into(); + mgr.prompt(&task.id, text("auth"), false).await.unwrap(); + } + wait_for(&mgr, &task.id, AgentStatus::Failed).await; + let entries = mgr.entries(&task.id, None).unwrap(); + let error = entries + .iter() + .find_map(|e| match e { + AgentEntry::Error { message, .. } => Some(message), + _ => None, + }) + .expect("authentication error"); + assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}"); + assert!(error.contains(&*program.to_string_lossy()), "{error}"); + assert!(error.ends_with(" login` in a terminal."), "{error}"); + mgr.shutdown(); + } +} + #[tokio::test(flavor = "multi_thread")] async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-jsonrpc/src/lib.rs b/crates/ide-jsonrpc/src/lib.rs index 6aa79979b..a44c98a15 100644 --- a/crates/ide-jsonrpc/src/lib.rs +++ b/crates/ide-jsonrpc/src/lib.rs @@ -346,7 +346,7 @@ impl Drop for KillOnDrop { /// child's `PATH` (a caller's `env` PATH, else the login shell's, else ours), so /// that on Windows `PATHEXT` names (`codex` → `codex.cmd`) start; anything else /// is kept as given. -fn resolve_spawn_program(command: &str, env: &[(String, String)]) -> std::path::PathBuf { +pub fn resolve_spawn_program(command: &str, env: &[(String, String)]) -> std::path::PathBuf { let path = std::path::Path::new(command); if path.components().count() > 1 || path.is_absolute() { return path.to_path_buf(); diff --git a/src/features/agents/BuiltinAgents.tsx b/src/features/agents/BuiltinAgents.tsx index 13a4c879b..9b32a16bf 100644 --- a/src/features/agents/BuiltinAgents.tsx +++ b/src/features/agents/BuiltinAgents.tsx @@ -1,7 +1,7 @@ /** Built-in launch settings; each executable is staged independently until Apply. */ import { useCallback, useEffect, useState } from "react"; import type { AgentProvider } from "@/bindings"; -import { tx, useTx } from "@/platform/i18n"; +import { tx, useTxTemplate } from "@/platform/i18n"; import clsx from "clsx"; import { Button, Icon, IconButton, Input, List, Spinner, T } from "@/ui"; import { useSettingsForm } from "@/workbench/contributions"; @@ -242,7 +242,7 @@ const formatEnv = (env: Record) => /** Login hints are plain text with optional backtick-delimited command spans. */ function LoginHint({ text }: { text: string }) { - const translate = useTx(); + const translate = useTxTemplate(); return translate(text) .split(/(`[^`]+`)/g) .map((part, i) => (i % 2 === 1 ? {part.slice(1, -1)} : part)); diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index a33171219..35e97cb82 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -58,6 +58,7 @@ import { findRanges, formatDuration, formatSentAt } from "./conversation"; import { ImagePreview, imageSrc } from "./ImagePreview"; import { AgentReply } from "./AgentReply"; import { Markdown } from "./Markdown"; +import { useAgentErrorText } from "./loginHint"; import { branchLabel, entriesKey, @@ -866,12 +867,16 @@ const Entry = memo(function Entry({ return (
- {entry.message} +
); } }); +function AgentErrorText({ message }: { message: string }) { + return {useAgentErrorText(message)}; +} + function UserMessage({ task, entry, diff --git a/src/features/agents/loginHint.test.ts b/src/features/agents/loginHint.test.ts new file mode 100644 index 000000000..056c105cb --- /dev/null +++ b/src/features/agents/loginHint.test.ts @@ -0,0 +1,26 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { _resetI18nForTests, setLocale } from "@/platform/i18n"; +import { useAgentErrorText } from "./loginHint"; + +afterEach(() => _resetI18nForTests()); + +it.each([ + ["Run `cursor-agent login` in a terminal.", "在终端中运行 `cursor-agent login`。"], + ["Run `'/registry path/cursor-agent' login` in a terminal.", "在终端中运行 `'/registry path/cursor-agent' login`。"], + [ + "Run `& 'C:\\Agent Tools\\cursor-agent.cmd' login` in a terminal.", + "在终端中运行 `& 'C:\\Agent Tools\\cursor-agent.cmd' login`。", + ], + ["Run `'/registry/claude'` in a terminal and sign in.", "在终端中运行 `'/registry/claude'` 并登录。"], +])("translates sign-in guidance with its command intact: %s", async (hint, translated) => { + await setLocale("zh"); + const { result } = renderHook(() => useAgentErrorText(`Cursor needs you to sign in. ${hint}`)); + expect(result.current).toBe(`Cursor 需要你登录。${translated}`); +}); + +it("preserves external agent errors", async () => { + await setLocale("zh"); + const { result } = renderHook(() => useAgentErrorText("Agent says: authentication failed")); + expect(result.current).toBe("Agent says: authentication failed"); +}); diff --git a/src/features/agents/loginHint.ts b/src/features/agents/loginHint.ts new file mode 100644 index 000000000..231dc0a26 --- /dev/null +++ b/src/features/agents/loginHint.ts @@ -0,0 +1,9 @@ +import { useTx, useTxTemplate } from "@/platform/i18n"; + +/** Translate the IDE's sign-in guidance while preserving the agent's own errors. */ +export function useAgentErrorText(message: string): string { + const tx = useTx(); + const translate = useTxTemplate(); + const auth = /^(.+) needs you to sign in\. (.+)$/s.exec(message); + return auth ? tx`${auth[1]} needs you to sign in. ${translate(auth[2]!)}` : message; +} diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index c2fbc3ede..fa5e2c547 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1,4 +1,7 @@ { + "Run `{command}` in a terminal.": "Run `{command}` in a terminal.", + "Run `{command}` in a terminal and sign in.": "Run `{command}` in a terminal and sign in.", + "{name} needs you to sign in. {hint}": "{name} needs you to sign in. {hint}", "OpenIDEA or JetBrains settings": "OpenIDEA or JetBrains settings", "Import JetBrains Settings": "Import JetBrains Settings", "These settings will be imported:": "These settings will be imported:", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index dcb8f27c4..f086722b0 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,7 @@ { + "Run `{command}` in a terminal.": "在终端中运行 `{command}`。", + "Run `{command}` in a terminal and sign in.": "在终端中运行 `{command}` 并登录。", + "{name} needs you to sign in. {hint}": "{name} 需要你登录。{hint}", "Cannot copy reference": "无法复制引用", "Group actions": "分组操作", "New Project Group": "新建项目组",