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.
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-11-07 23:22:03 +08:00
1 parent ebc1c21836
commit 8509ca671f
10 files changed
+242 -12

No files matched your search

+9 -7
View File
@@ -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)
}
+134 -1
View File
@@ -81,6 +81,64 @@ fn builtins() -> Vec<ProviderSpec> {
.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 `<config>/agents/<id>.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();
+49
View File
@@ -11,6 +11,7 @@ struct FakeAgent {
calls: Mutex<Vec<String>>,
/// 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<Vec<Value>>,
}
@@ -87,6 +88,11 @@ impl Handler for FakeAgent {
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
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<FakeAgent>) -> 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();
+1 -1
View File
@@ -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();
+2 -2
View File
@@ -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<string, string>) =>
/** 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 ? <code key={i}>{part.slice(1, -1)}</code> : part));
+6 -1
View File
@@ -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 (
<div className={styles.error} role="alert">
<Icon name="error" size={14} />
<span>{entry.message}</span>
<AgentErrorText message={entry.message} />
</div>
);
}
});
function AgentErrorText({ message }: { message: string }) {
return <span>{useAgentErrorText(message)}</span>;
}
function UserMessage({
task,
entry,
+26
View File
@@ -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");
});
+9
View File
@@ -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;
}
+3
View File
@@ -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:",
+3
View File
@@ -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": "新建项目组",