From 8509ca671f57edab9dcf7873c6cf25ffdbdfab40 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:22:03 +0800 Subject: [PATCH] fix(agents): sign-in hint names the CLI the IDE actually runs (#2018) Fixes #1913. The sign-in hint named the bare command (`cursor-agent login`), but the IDE launches agents through its own resolution (registry installs under the config dir, the login shell's PATH, PATHEXT shims), so the named command could be missing from the terminal's PATH. The agent spec is now resolved once before connecting, and when that executable isn't what a PATH lookup of the bare name finds, every built-in hint names the resolved path, quoted for PowerShell or POSIX shells. Hints for separate login tools (claude-acp, codex-acp, vibe) and custom agents are unchanged; the hint is translated through templates. --- crates/ide-agent/src/lib.rs | 16 +-- crates/ide-agent/src/provider.rs | 135 +++++++++++++++++++++++++- crates/ide-agent/src/tests.rs | 49 ++++++++++ crates/ide-jsonrpc/src/lib.rs | 2 +- src/features/agents/BuiltinAgents.tsx | 4 +- src/features/agents/SessionEditor.tsx | 7 +- src/features/agents/loginHint.test.ts | 26 +++++ src/features/agents/loginHint.ts | 9 ++ src/platform/locales/text/en.json | 3 + src/platform/locales/text/zh.json | 3 + 10 files changed, 242 insertions(+), 12 deletions(-) create mode 100644 src/features/agents/loginHint.test.ts create mode 100644 src/features/agents/loginHint.ts 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": "新建项目组",