From 11911a12c6f951c0ed4d502c72dde9cb17ba7de8 Mon Sep 17 00:00:00 2001 From: xtr-hub <3786329580@qq.com> Date: Sun, 8 Nov 2026 16:09:59 +0800 Subject: [PATCH] fix(agents): honor full-access settings for tool approvals (#1702) (#2109) The client only auto-approved permission requests for Cursor's openidea.cursor.approval=bypass setting. Agents whose current mode is full-access / bypassPermissions / yolo, or whose permissions option is on, still showed permission cards while the composer displayed Full Access.\n\nTreat those selected settings as approval bypasses, matching the composer's bypassDefaults. Re-evaluate pending requests when the setting arrives late or changes while a request waits.\n\nCloses #1702 --- crates/ide-agent/src/config.rs | 70 ++++++++++++-- crates/ide-agent/src/lib.rs | 7 +- crates/ide-agent/src/tests.rs | 163 ++++++++++++++++++++++++++++++++- 3 files changed, 231 insertions(+), 9 deletions(-) diff --git a/crates/ide-agent/src/config.rs b/crates/ide-agent/src/config.rs index a9ab1bc60..6c41c1c59 100644 --- a/crates/ide-agent/src/config.rs +++ b/crates/ide-agent/src/config.rs @@ -35,20 +35,32 @@ pub(crate) fn add_client_options(provider: &str, config: &mut Vec bool { - provider == "cursor" && config.iter().any(|o| o.id == CURSOR_APPROVAL && o.current.as_deref() == Some("bypass")) +/// Whether the current settings approve tool requests without asking: a bypass mode, +/// an allow-all permissions option, or a bypass approval policy. +pub(crate) fn bypass_approvals(config: &[AgentConfigOption]) -> bool { + let selected = |category: &str| { + config.iter().find(|o| o.category.as_deref() == Some(category)).and_then(|o| o.current.as_deref()) + }; + selected("mode").is_some_and(is_bypass_mode) + || selected("permissions").is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true")) + || selected("approval").is_some_and(|v| v.eq_ignore_ascii_case("bypass")) } /// The mode that approves everything without asking (`bypassPermissions`, `yolo`, `full-access`…), if the agent has one. pub(crate) fn bypass_mode(options: &[AgentConfigOption]) -> Option<(&AgentConfigOption, &AgentConfigValue)> { let option = options.iter().find(|o| o.category.as_deref() == Some("mode"))?; - let value = option.values.iter().find(|v| { - let m: String = v.value.to_lowercase().chars().filter(char::is_ascii_lowercase).collect(); - m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous" - })?; + let value = option.values.iter().find(|v| is_bypass_mode(&v.value))?; Some((option, value)) } +fn is_bypass_mode(mode: &str) -> bool { + // Mode ids can be URL-like (`…/session-modes#yolo`), as the composer's mode icon also assumes. + let lower = mode.to_lowercase(); + let m: String = + lower.rsplit(['#', '/']).next().unwrap_or_default().chars().filter(char::is_ascii_lowercase).collect(); + m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous" +} + /// The settings in a `session/new` or `session/load` result: its select `configOptions`, /// plus the older `modes` / `models` fields for what those don't cover. pub(crate) fn options_of(result: &Value) -> Vec { @@ -267,6 +279,52 @@ mod tests { assert_eq!(ids(&c), [("model", Some("b")), ("mode", Some("code"))]); } + #[test] + fn selected_bypass_settings_approve_without_asking() { + let full_access = options_of(&json!({ + "modes": {"currentModeId": "full-access", "availableModes": [ + {"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"} + ]} + })); + assert!(bypass_approvals(&full_access)); + assert_eq!(bypass_mode(&full_access).map(|(_, v)| v.value.as_str()), Some("full-access")); + + let asking = options_of(&json!({ + "modes": {"currentModeId": "ask", "availableModes": [ + {"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"} + ]} + })); + assert!(!bypass_approvals(&asking)); + assert_eq!( + bypass_mode(&asking).map(|(_, v)| v.value.as_str()), + Some("full-access"), + "the offered bypass mode stays available" + ); + + let permissions = |current: &str| { + options_of(&json!({"configOptions": [{ + "id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select", + "currentValue": current, "options": [{"value": "off"}, {"value": "on"}] + }]})) + }; + assert!(bypass_approvals(&permissions("on"))); + assert!(bypass_approvals(&permissions("TRUE"))); + assert!(!bypass_approvals(&permissions("off"))); + + let approval = options_of(&json!({"configOptions": [{ + "id": "approval", "name": "Approvals", "category": "approval", "type": "select", + "currentValue": "bypass", "options": [{"value": "ask"}, {"value": "bypass"}] + }]})); + assert!(bypass_approvals(&approval)); + + // The composer's mode icon splits URL-like ids on `#` / `/`; the backend must agree. + let url_mode = options_of(&json!({ + "modes": {"currentModeId": "https://agentclientprotocol.com/protocol/session-modes#yolo", + "availableModes": [{"id": "https://agentclientprotocol.com/protocol/session-modes#yolo"}]} + })); + assert!(bypass_approvals(&url_mode)); + } + #[test] fn commands_and_usage() { let cmds = commands_of(&json!({"availableCommands": [ diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 676eb3036..387a67a64 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -1581,6 +1581,7 @@ impl Task { .collect() }; if due.is_empty() { + self.approve_pending(); return; } let task = self.clone(); @@ -1610,6 +1611,7 @@ impl Task { Err(e) => tracing::warn!("agent task {}: setting {} to {value} failed: {e}", task.id, option.id), } } + task.approve_pending(); task.persist(); task.emit_task(); }); @@ -1726,6 +1728,9 @@ impl Task { if kind == "config_option_update" && !self.replaying.load(Ordering::Acquire) { self.apply_late_choices(); } + if !self.replaying.load(Ordering::Acquire) && kind == "current_mode_update" { + self.approve_pending(); + } return; } if self.replaying.load(Ordering::Acquire) { @@ -1857,7 +1862,7 @@ impl Task { fn approve_pending(&self) { let s = self.state.lock(); - if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.provider, &s.task.config) { + if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.config) { return; } let mut pending = self.permissions.lock(); diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index fd53447ab..3589f366c 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -35,6 +35,8 @@ struct FakeAgent { /// Without `loadSession` (a session lives only as long as its process). no_load: bool, auth_on_new: bool, + /// Hold the late mode reply until the prompt's permission request is already waiting. + hold_late_mode: bool, empty_config: AtomicBool, /// Config options `session/new` reports after the model one (per-model efforts, say). extra_options: Mutex>, @@ -128,6 +130,13 @@ impl Handler for FakeAgent { { return Err((codes::INTERNAL_ERROR, "Authentication required".into())); } + if self.hold_late_mode + && method == "session/set_config_option" + && params.get("value").and_then(Value::as_str) == Some("full-access") + { + // Let the prompt's permission request reach the task before the mode changes. + std::thread::sleep(Duration::from_millis(100)); + } let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned(); match method { "initialize" => { @@ -200,6 +209,29 @@ impl Handler for FakeAgent { self.say(&session, "ok"); return Ok(json!({"stopReason": "end_turn"})); } + if text == "late full access" { + // The mode option appears only now; the user's choice is applied late. + self.update( + &session, + json!({"sessionUpdate": "config_option_update", "configOptions": [ + {"id": "mode", "category": "mode", "type": "select", "currentValue": "ask", + "options": [{"value": "ask", "name": "Ask"}, {"value": "full-access", "name": "Full Access"}]} + ]}), + ); + let peer = self.latest(); + let answer: Value = tokio::runtime::Handle::current() + .block_on(peer.request( + "session/request_permission", + json!({"sessionId": session, "toolCall": {"toolCallId": "late", "title": "Run cargo test"}, + "options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"}, + {"optionId": "no", "name": "Reject", "kind": "reject_once"}]}), + None, + )) + .unwrap(); + let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none"); + self.say(&session, &format!("late chose {chosen}")); + return Ok(json!({"stopReason": "end_turn"})); + } if text == "ask" { let peer = self.latest(); let answer: Value = tokio::runtime::Handle::current() @@ -1438,12 +1470,12 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() { mgr.prompt(&task.id, text("late option"), false).await.unwrap(); wait_for(&mgr, &task.id, AgentStatus::Ready).await; let t = mgr.task(&task.id).unwrap(); - assert!(config::bypass_approvals("cursor", &t.state.lock().task.config)); + assert!(config::bypass_approvals(&t.state.lock().task.config)); t.kill(); mgr.prompt(&task.id, text("Fix again"), false).await.unwrap(); wait_for(&mgr, &task.id, AgentStatus::Ready).await; assert!(!agent.calls.lock().iter().any(|c| c.contains("bypass"))); - assert!(config::bypass_approvals("cursor", &mgr.shared.known_config("cursor"))); + assert!(config::bypass_approvals(&mgr.shared.known_config("cursor"))); mgr.set_config(&task.id, config::CURSOR_APPROVAL, "ask".into()).await.unwrap(); mgr.prompt(&task.id, text("Fix once more"), false).await.unwrap(); wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await; @@ -1534,6 +1566,133 @@ async fn cursor_approval_choice_applies_before_first_prompt() { mgr.shutdown(); } +#[tokio::test(flavor = "multi_thread")] +async fn a_full_access_mode_approves_tool_requests() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + *agent.extra_options.lock() = vec![json!({ + "id": "mode", "category": "mode", "type": "select", "currentValue": "ask", + "options": [{"value": "ask", "name": "Ask"}, {"value": "full-access", "name": "Full Access"}] + })]; + let mgr = manager(tmp.path(), agent.clone()); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("Fix"), + provider: "claude".into(), + config: vec![AgentConfigChoice { id: "mode".into(), value: "full-access".into() }], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"full-access\""))); + assert!( + mgr.entries(&task.id, None) + .unwrap() + .iter() + .any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow")) + ); + + // Choosing Full Access while the request is already waiting also approves it. + let waiting = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("Fix"), + provider: "claude".into(), + config: Vec::new(), + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &waiting.id, AgentStatus::NeedsInput).await; + mgr.set_config(&waiting.id, "mode", "full-access".into()).await.unwrap(); + wait_for(&mgr, &waiting.id, AgentStatus::Ready).await; + assert!( + mgr.entries(&waiting.id, None) + .unwrap() + .iter() + .any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow")) + ); + mgr.shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_late_full_access_option_approves_a_pending_request() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent { hold_late_mode: true, ..Default::default() }); + let mgr = manager(tmp.path(), agent.clone()); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("late full access"), + provider: "claude".into(), + config: vec![AgentConfigChoice { id: "mode".into(), value: "full-access".into() }], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let task = mgr.task(&task.id).unwrap(); + let (status, busy) = { + let s = task.state.lock(); + (s.task.status, s.busy) + }; + if status == AgentStatus::Ready && !busy { + break; + } + if std::time::Instant::now() > deadline { + let _ = mgr.cancel(&task.id, None); + mgr.shutdown(); + panic!("late Full Access did not approve the pending request"); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"full-access\""))); + assert!( + mgr.entries(&task.id, None) + .unwrap() + .iter() + .any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow")) + ); + mgr.shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_allow_all_permissions_option_approves_tool_requests() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + *agent.extra_options.lock() = vec![json!({ + "id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select", "currentValue": "off", + "options": [{"value": "off", "name": "Ask"}, {"value": "on", "name": "Full Access"}] + })]; + let mgr = manager(tmp.path(), agent.clone()); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("Fix"), + provider: "claude".into(), + config: vec![AgentConfigChoice { id: "allow_all".into(), value: "on".into() }], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"on\""))); + assert!( + mgr.entries(&task.id, None) + .unwrap() + .iter() + .any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow")) + ); + mgr.shutdown(); +} + #[tokio::test(flavor = "multi_thread")] async fn archived_tasks_can_be_restored_and_continue_their_session() { let tmp = tempfile::tempdir().unwrap();