diff --git a/.impeccable/config.json b/.impeccable/config.json new file mode 100644 index 00000000..7eaee6b3 --- /dev/null +++ b/.impeccable/config.json @@ -0,0 +1,26 @@ +{ + "detector": { + "ignoreRules": [], + "ignoreFiles": [], + "ignoreValues": [ + { + "rule": "side-tab", + "value": "*", + "files": [ + "src/components/agent-message-bubble.vue" + ], + "createdAt": "2026-10-27T07:14:06.715Z", + "reason": "Agent review: this neutral border marks semantic Markdown blockquotes, not decorative card accents." + }, + { + "rule": "bounce-easing", + "value": "typing-bounce", + "files": [ + "src/components/agent-message-bubble.vue" + ], + "createdAt": "2026-10-27T07:14:06.764Z", + "reason": "Agent review: the existing typing dots pulse from scale 0 to 1 with ease-in-out; there is no bounce, overshoot, or elastic easing." + } + ] + } +} diff --git a/DESIGN.md b/DESIGN.md index 966c699d..e7d17a97 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -331,4 +331,6 @@ Check each affected surface. Documentation-only changes need source, link, and i ### AI permission controls -Connection dialogs expose a collapsed AI permissions section shared by Data Studio and external MCP. Default to system inheritance, show effective allowed actions, and reveal per-action Inherit / Allow / Deny under Customize. The system settings page edits action defaults, without a separate connection allowlist or MCP exposure switch. Preserve shared Select, Label, Button, neutral surfaces, localized English/Chinese copy, keyboard focus, and loading/error/retry states. See [AI permissions](docs/AI_PERMISSIONS.md) for enforcement and migration behavior. +Per-connection permission editing uses the existing local Ultimate gate and inline upgrade prompt; Community connection edits preserve saved permission decisions. Connection dialogs group the agent prompt with a collapsed AI permissions section shared by Data Studio and external MCP, separate from SSL and SSH tunnel blocks. Default to system inheritance, show effective allowed actions, and offer visible radio choices for access levels, including Custom. A short description below the option group reflects the selected access level. Custom reveals per-action switches whose labels show Use default (decision) for inherited actions and the explicit decision otherwise. Presets and custom editing change the same permission values and do not form separate policy layers. The system settings page edits action defaults, without a separate connection allowlist or MCP exposure switch. Separate shared AI defaults from MCP service settings using the existing settings cards. Use switches for binary actions, and preserve shared RadioGroup, Switch, Label, Button, neutral surfaces, localized English/Chinese copy, keyboard focus, and loading/error/retry states. See [AI permissions](docs/AI_PERMISSIONS.md) for enforcement and migration behavior. + +Data Studio tool outcomes remain visible outside collapsible result details. Policy blocks use a local destructive notice naming the connection, required action, and recorded permission source; user rejection uses warning feedback and explicit rejection copy. Neither uses a success verb. A blocked conversation shows a paused status near the composer. Review permissions edits the blocked connection using the existing connection permission control, including inherited denials, with Save and Back to chat in one footer row. Global defaults remain in Settings. Retry request starts a fresh checked turn. Keep these notices and outcomes when restoring history; an unknown or interrupted outcome must not appear successful. diff --git a/docs/AI_PERMISSIONS.md b/docs/AI_PERMISSIONS.md index 9aa85622..4514d273 100644 --- a/docs/AI_PERMISSIONS.md +++ b/docs/AI_PERMISSIONS.md @@ -23,3 +23,23 @@ Agent executors are bound to a conversation and its connection/source identities Preparation rejects denied calls before displaying confirmation. A queued call whose confirmation requirements become stricter returns to the existing approval flow. Permission denials are terminal and do not trigger transport retries; approval never bypasses a later denial. Rechecks occur at dispatch and do not cancel operations already dispatched or erase context already sent to a model. The host-hook API is introduced by [data-studio-agent #35](https://github.com/geek-fun/data-studio-agent/pull/35). Merge that runtime implementation before merging its consumers. Keep the exact compatible revision until a release containing these hooks exists; after the runtime is merged and released, update both the manifest and lockfile to that release. Older release tags do not provide this API. + +## Permission controls + +Settings separate MCP service controls from the AI defaults shared by Data Studio and external MCP clients. Access levels use the existing settings radio pattern; a short description below the option group updates with the selected access level. Custom is an access choice that reveals individual actions; it edits the same values as the presets. Choosing Custom alone does not change permissions. + +Connection dialogs group the agent prompt with the AI permissions section, separate from the SSL and SSH tunnel blocks. They default to system inheritance. Custom actions use switches: inherited actions show Use default (with the current decision), explicit actions show their decision with the connection source, and toggling sets an explicit decision. Within the connection section, saved custom combinations show their action controls automatically. Choosing a preset sets explicit decisions for every action; choosing Use system default clears them. + +Editing AI permission defaults and per-connection decisions requires the existing local Ultimate entitlement, including eligible version-locked releases. Community users see the existing upgrade prompt in connection dialogs. Ordinary connection edits preserve saved decisions. Permission cleanup after deletion remains available only when the connection no longer exists; it cannot reset the permissions of a saved connection. + +## Blocked actions and conversation history + +Ask and Auto both run permitted reads automatically. Ask requests approval for ordinary data writes; Auto runs them automatically. Both modes request approval for permitted deletes and structure/administration changes unless a saved confirmation rule applies. The application displays the approval card when the agent submits a tool call; model instructions do not replace that gate. Approval does not grant an action denied by the connection chain or conversation restrictions. + +A policy denial records the tool result and pauses the agent through the existing runtime cancellation boundary. The host persists the conversation as blocked and reports that state after the run has settled. Already dispatched work is not undone. Other unresolved calls show an unavailable outcome instead of success. Explicit user rejection remains distinct from a policy block. + +Each denied call retains a visible notice with its connection, required action, and decision source when recorded. For connection access denials, Review permissions always edits the blocked connection, including when its denied decision is inherited from system defaults. It never changes global defaults from chat. Conversation restrictions and saved confirmation rules retain their own recovery controls. Save and Back to chat share one dialog footer row. Saving never executes the blocked action. Retry request starts a new agent turn with the original tool arguments, fresh permissions, and normal confirmation; it does not replay earlier successful work automatically or bypass the application gate. + +History loads persisted tool-call statuses and correlated results rather than assuming every call succeeded. Denials and failures remain visible after reopening. Interrupted or missing outcomes are shown as unavailable; legacy records whose permission source was never recorded retain a generic permission notice. + +Each agent turn receives a backend-resolved snapshot of current effective actions for its attached connections, including management access. This planning context does not replace live dispatch authorization. Historical denials do not establish current access after a permission change, and an explicit retry must not be reported as failed without a new tool result. System AI defaults are application policy, not operating-system or database privileges. Earlier blocked calls remain labeled as previous attempts, with raw diagnostics collapsed behind their structured notice. diff --git a/src-tauri/src/agent/events.rs b/src-tauri/src/agent/events.rs new file mode 100644 index 00000000..8f35a0ba --- /dev/null +++ b/src-tauri/src/agent/events.rs @@ -0,0 +1,218 @@ +use std::sync::Mutex; + +use data_studio_agent::traits::{CancelMap, EventEmitter}; +use serde_json::Value; + +use crate::ai_permissions::AccessDenial; + +pub(crate) struct AgentRunEmitter { + pub inner: E, + pub cancel_map: CancelMap, + pub blocked: Mutex>, +} + +impl EventEmitter for AgentRunEmitter { + fn emit(&self, event: &str, mut payload: Value) { + if event == "agent-loop-tool-result" && payload["denied"] == true { + let denial = AccessDenial::from_message( + payload["envelope"]["summary"] + .as_str() + .unwrap_or("Access denied"), + ); + payload["envelope"]["summary"] = Value::String(denial.message.clone()); + payload["denial"] = serde_json::to_value(&denial).unwrap(); + if denial.kind != "user" { + let mut blocked = self + .blocked + .lock() + .unwrap_or_else(|error| error.into_inner()); + if blocked.is_none() { + *blocked = Some(payload.clone()); + if let Some(session) = payload["session_id"].as_str() { + // Use the runtime's cancellation boundary after it records the denied result. + let _ = data_studio_agent::loop_runner::cancel_agent_loop( + session, + &self.cancel_map, + ); + } + } + } + } + let blocked = self + .blocked + .lock() + .unwrap_or_else(|error| error.into_inner()) + .is_some(); + if blocked + && (event == "agent-loop-done" + || event == "agent-loop-iteration" + || (event == "agent-loop-error" && payload["error"] == "cancelled")) + { + return; + } + self.inner.emit(event, payload); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::{collections::HashMap, sync::Arc}; + + #[derive(Default)] + struct Events(Mutex>); + impl EventEmitter for Events { + fn emit(&self, event: &str, payload: Value) { + self.0.lock().unwrap().push((event.into(), payload)); + } + } + + #[test] + fn permission_denial_cancels_further_work_without_hiding_the_result() { + let (tx, mut rx) = tokio::sync::oneshot::channel(); + let emitter = AgentRunEmitter { + inner: Events::default(), + cancel_map: Arc::new(Mutex::new(HashMap::from([("chat".into(), tx)]))), + blocked: Mutex::new(None), + }; + let denial = AccessDenial::permission( + crate::ai_permissions::Permission::Manage, + "db", + "connection", + ); + emitter.emit("agent-loop-tool-result", json!({"session_id":"chat","tool_call_id":"call","denied":true,"envelope":{"summary":denial.encode()}})); + assert_eq!(rx.try_recv(), Ok(())); + emitter.emit("agent-loop-error", json!({"error":"cancelled"})); + emitter.emit("agent-loop-iteration", json!({})); + let events = emitter.inner.0.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].1["denial"]["source"], "connection"); + assert_eq!(events[0].1["envelope"]["summary"], denial.message); + assert!(emitter.blocked.lock().unwrap().is_some()); + } + + #[test] + fn user_rejection_does_not_become_a_permission_block() { + let emitter = AgentRunEmitter { + inner: Events::default(), + cancel_map: Arc::new(Mutex::new(HashMap::new())), + blocked: Mutex::new(None), + }; + emitter.emit( + "agent-loop-tool-result", + json!({"denied":true,"envelope":{"summary":"Tool call was not approved"}}), + ); + assert!(emitter.blocked.lock().unwrap().is_none()); + assert_eq!( + emitter.inner.0.lock().unwrap()[0].1["denial"]["kind"], + "user" + ); + } + + #[tokio::test] + async fn denied_preflight_persists_result_and_stops_before_another_model_turn() { + use async_trait::async_trait; + use data_studio_agent::{ + storage::{db::AgentDb, session_store::SqliteSessionStore}, + tool_executor::{ToolCallPreparation, ToolEnvelope, ToolExecutor}, + }; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct Deny; + #[async_trait] + impl ToolExecutor for Deny { + fn prepare( + &self, + _: &str, + _: &Value, + _: &Value, + _: &HashMap, + ) -> Result { + Err(AccessDenial::permission( + crate::ai_permissions::Permission::Manage, + "db", + "system", + ) + .encode()) + } + async fn execute(&self, _: &str, _: &Value, _: &Value) -> Result { + panic!("a blocked action must not execute") + } + } + + let requests = Arc::new(AtomicUsize::new(0)); + let count = requests.clone(); + let response = format!( + "data: {}\n\ndata: {}\n\ndata: [DONE]\n\n", + json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call","type":"function","function":{"name":"sqlkit__execute_ddl","arguments":"{\"connection_id\":\"db\",\"sql\":\"CREATE TABLE example(id INT)\"}"}}]}}]}), + json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}) + ); + let router = axum::Router::new().route( + "/v1/chat/completions", + axum::routing::post(move || { + count.fetch_add(1, Ordering::SeqCst); + let response = response.clone(); + async move { ([("content-type", "text/event-stream")], response) } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let db = AgentDb(Arc::new(Mutex::new( + rusqlite::Connection::open_in_memory().unwrap(), + ))); + data_studio_agent::storage::db::migrate(&db).unwrap(); + db.0.lock().unwrap().execute("INSERT INTO agent_sessions (id,title,created_at,updated_at) VALUES ('chat','Chat',0,0)", []).unwrap(); + let cancel_map = Arc::new(Mutex::new(HashMap::new())); + let emitter = AgentRunEmitter { + inner: Events::default(), + cancel_map: cancel_map.clone(), + blocked: Mutex::new(None), + }; + // An Anthropic-family model keeps token accounting on the cheap + // character heuristic; the OpenAI tiktoken encoders are rebuilt per + // message and can blow the loop timeout on CI runners. + let settings = json!({"baseUrl":format!("http://{address}"),"model":"claude-3-5-haiku","apiKey":"synthetic","proxyMode":"none","tools":[{"type":"function","function":{"name":"sqlkit__execute_ddl","parameters":{"type":"object"}}}],"maxIterations":3}); + let result = tokio::time::timeout( + std::time::Duration::from_secs(10), + data_studio_agent::loop_runner::run_agent_loop( + "chat", + "Create example", + &settings, + &SqliteSessionStore::new(db.clone()), + &emitter, + &Deny, + HashMap::from([("db".into(), json!({"connectionId":"db"}))]), + Value::Null, + &Arc::new(Mutex::new(HashMap::new())), + &cancel_map, + &|_| false, + &|_, _| false, + ), + ) + .await + .unwrap(); + server.abort(); + assert_eq!(result.unwrap_err(), "cancelled"); + assert_eq!(requests.load(Ordering::SeqCst), 1); + assert!(emitter.blocked.lock().unwrap().is_some()); + let conn = db.0.lock().unwrap(); + let status: String = conn + .query_row( + "SELECT status FROM agent_tool_calls WHERE id='call'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(status, "denied"); + let result_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM agent_messages WHERE role='tool'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(result_count, 1); + } +} diff --git a/src-tauri/src/agent/mod.rs b/src-tauri/src/agent/mod.rs index 9ed99b14..ff18e4c1 100644 --- a/src-tauri/src/agent/mod.rs +++ b/src-tauri/src/agent/mod.rs @@ -1,4 +1,5 @@ // App-specific agent modules — shared logic is in data-studio-agent-lib. +pub(crate) mod events; pub mod executor; pub mod query_history; pub mod session_store; diff --git a/src-tauri/src/agent/session_store.rs b/src-tauri/src/agent/session_store.rs index 8696fa6f..a5d6d211 100644 --- a/src-tauri/src/agent/session_store.rs +++ b/src-tauri/src/agent/session_store.rs @@ -22,6 +22,18 @@ pub struct AgentMessageRow { pub role: String, pub content: String, pub created_at: i64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_calls: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentToolCallRow { + pub id: String, + pub tool_name: String, + pub arguments: String, + pub status: String, + pub result: Option, + pub denial: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -221,11 +233,18 @@ pub fn load_session_messages( .0 .lock() .map_err(|e| format!("Failed to lock db: {}", e))?; + load_session_history(&conn, &session_id) +} + +fn load_session_history( + conn: &rusqlite::Connection, + session_id: &str, +) -> Result, String> { let mut stmt = conn - .prepare("SELECT id, session_id, role, content, created_at FROM agent_messages WHERE session_id = ?1 ORDER BY created_at ASC") + .prepare("SELECT id, session_id, role, content, created_at FROM agent_messages WHERE session_id = ?1 ORDER BY rowid ASC") .map_err(|e| format!("Failed to prepare: {}", e))?; - let messages = stmt + let mut messages: Vec = stmt .query_map(rusqlite::params![session_id], |row| { Ok(AgentMessageRow { id: row.get(0)?, @@ -233,11 +252,57 @@ pub fn load_session_messages( role: row.get(2)?, content: row.get(3)?, created_at: row.get(4)?, + tool_calls: Vec::new(), }) }) .map_err(|e| format!("Failed to query: {}", e))? - .filter_map(|r| r.ok()) + .collect::>() + .map_err(|e| e.to_string())?; + + let results: std::collections::HashMap = messages + .iter() + .filter(|message| message.role == "tool") + .filter_map(|message| { + let value: serde_json::Value = serde_json::from_str(&message.content).ok()?; + Some(( + value["tool_call_id"].as_str()?.to_owned(), + value["content"].as_str()?.to_owned(), + )) + }) .collect(); + let mut stmt = conn.prepare("SELECT t.message_id, t.id, t.tool_name, t.arguments, t.status, (SELECT full_result FROM tool_result_store r WHERE r.tool_call_id=t.id ORDER BY r.rowid DESC LIMIT 1) FROM agent_tool_calls t WHERE t.session_id=?1 ORDER BY t.rowid ASC").map_err(|e| e.to_string())?; + let calls = stmt + .query_map([session_id], |row| { + Ok(( + row.get::<_, String>(0)?, + AgentToolCallRow { + id: row.get(1)?, + tool_name: row.get(2)?, + arguments: row.get(3)?, + status: row.get(4)?, + result: row.get(5)?, + denial: None, + }, + )) + }) + .map_err(|e| e.to_string())?; + for call in calls { + let (message_id, mut call) = call.map_err(|e| e.to_string())?; + call.result = results.get(&call.id).cloned().or(call.result); + if call.status == "failed" && call.result.as_deref() == Some("Tool call was cancelled.") { + call.status = "interrupted".into(); + } + if call.status == "denied" { + let denial = crate::ai_permissions::AccessDenial::from_message( + call.result.as_deref().unwrap_or("Access denied"), + ); + call.result = Some(denial.message.clone()); + call.denial = Some(serde_json::to_value(denial).map_err(|e| e.to_string())?); + } + if let Some(message) = messages.iter_mut().find(|message| message.id == message_id) { + message.tool_calls.push(call); + } + } Ok(messages) } @@ -485,9 +550,61 @@ pub fn recover_stuck_sessions(agent_db: State<'_, AgentDb>) -> Result<(), String ) .map_err(|e| format!("Failed to recover: {}", e))?; conn.execute( - "UPDATE agent_tool_calls SET status = 'failed' WHERE status IN ('pending', 'approved')", + "UPDATE agent_tool_calls SET status = 'interrupted' WHERE status IN ('pending', 'approved')", [], ) .map_err(|e| format!("Failed to recover tool calls: {}", e))?; Ok(()) } + +#[cfg(test)] +mod history_tests { + use super::*; + use serde_json::json; + use std::sync::{Arc, Mutex}; + + #[test] + fn history_restores_outcomes_results_and_denial_context_without_promoting_pending_calls() { + let db = AgentDb(Arc::new(Mutex::new( + rusqlite::Connection::open_in_memory().unwrap(), + ))); + data_studio_agent::storage::db::migrate(&db).unwrap(); + let conn = db.0.lock().unwrap(); + conn.execute("INSERT INTO agent_sessions (id,title,created_at,updated_at) VALUES ('chat','Chat',0,0)", []).unwrap(); + conn.execute("INSERT INTO agent_messages (id,session_id,role,content,created_at) VALUES ('assistant','chat','assistant',?1,0)", [json!({"content":"","tool_calls":[]}).to_string()]).unwrap(); + let denied = crate::ai_permissions::AccessDenial::permission( + crate::ai_permissions::Permission::Manage, + "db", + "connection", + ); + let outcomes = [ + ("completed", "completed", "Created example".to_owned()), + ("failed", "failed", "Database execution failed".to_owned()), + ("blocked", "denied", denied.encode()), + ( + "rejected", + "denied", + "Tool call 'ddl' was denied by the user. Try an alternative approach.".to_owned(), + ), + ]; + for (id, status, result) in outcomes { + conn.execute("INSERT INTO agent_tool_calls (id,message_id,session_id,tool_name,arguments,status,created_at) VALUES (?1,'assistant','chat','ddl','{}',?2,0)", rusqlite::params![id,status]).unwrap(); + conn.execute("INSERT INTO agent_messages (id,session_id,role,content,created_at) VALUES (?1,'chat','tool',?2,0)", rusqlite::params![format!("result-{id}"), json!({"tool_call_id":id,"content":result}).to_string()]).unwrap(); + } + conn.execute("INSERT INTO agent_tool_calls (id,message_id,session_id,tool_name,arguments,status,created_at) VALUES ('unfinished','assistant','chat','ddl','{}','approved',0)", []).unwrap(); + let messages = load_session_history(&conn, "chat").unwrap(); + let calls = &messages[0].tool_calls; + assert_eq!(calls.len(), 5); + assert_eq!(calls[0].status, "completed"); + assert_eq!(calls[0].result.as_deref(), Some("Created example")); + assert_eq!(calls[1].status, "failed"); + assert_eq!(calls[2].status, "denied"); + assert_eq!(calls[2].denial.as_ref().unwrap()["source"], "connection"); + assert_eq!(calls[2].denial.as_ref().unwrap()["action"], "manage"); + assert_eq!(calls[2].result.as_deref(), Some(denied.message.as_str())); + assert_eq!(calls[3].denial.as_ref().unwrap()["kind"], "user"); + assert_eq!(calls[4].status, "approved"); + assert!(calls[4].result.is_none()); + assert_eq!(messages[1].id, "result-completed"); + } +} diff --git a/src-tauri/src/agent_adapters.rs b/src-tauri/src/agent_adapters.rs index 28034a36..19cf747a 100644 --- a/src-tauri/src/agent_adapters.rs +++ b/src-tauri/src/agent_adapters.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use data_studio_agent as lib; use data_studio_agent::storage; -use data_studio_agent::traits::{CancelMap, ConfirmMap, EventEmitter}; +use data_studio_agent::traits::{CancelMap, ConfirmMap, EventEmitter, SessionStore}; use serde_json::Value; use tauri::{AppHandle, Emitter, Manager, State}; @@ -41,12 +41,16 @@ pub async fn run_agent_loop( )?; let db_state: State = app.state::(); let store = storage::session_store::SqliteSessionStore::new(db_state.inner().clone()); - let emitter = TauriEmitter(app.clone()); let confirm_state: State = app.state::(); let confirm_map: ConfirmMap = confirm_state.inner().clone(); let cancel_state: State = app.state::(); let cancel_map: CancelMap = cancel_state.inner().clone(); + let emitter = crate::agent::events::AgentRunEmitter { + inner: TauriEmitter(app.clone()), + cancel_map: cancel_map.clone(), + blocked: std::sync::Mutex::new(None), + }; let connections: HashMap = settings .get("connections") @@ -81,7 +85,7 @@ pub async fn run_agent_loop( // Required by the shared loop API; authorization is owned by the executor at both stages. let legacy_deny_hook = |_: &str, _: Option<&str>| false; - lib::loop_runner::run_agent_loop( + let result = lib::loop_runner::run_agent_loop( &session_id, &user_message, &settings, @@ -95,7 +99,20 @@ pub async fn run_agent_loop( &is_parallel_ok, &legacy_deny_hook, ) - .await + .await; + let blocked = emitter + .blocked + .lock() + .map_err(|error| error.to_string())? + .clone(); + if let Some(payload) = blocked { + if result.is_ok() || result.as_ref().is_err_and(|error| error == "cancelled") { + store.update_session_status(&session_id, "blocked").await?; + emitter.inner.emit("agent-loop-blocked", payload); + return Ok(()); + } + } + result } #[tauri::command] diff --git a/src-tauri/src/ai_permissions/chain.rs b/src-tauri/src/ai_permissions/chain.rs index 5ca3fd8f..324d5a0b 100644 --- a/src-tauri/src/ai_permissions/chain.rs +++ b/src-tauri/src/ai_permissions/chain.rs @@ -1,4 +1,4 @@ -use super::{policy::PolicyAction, AiPolicy}; +use super::{policy::PolicyAction, AccessDenial, AiPolicy}; use data_studio_agent::capabilities::types::{Capability, RiskLevel, SourceKind}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -154,26 +154,29 @@ impl AiPolicy { } let id = id.ok_or("A saved connection must be specified for database operations")?; let effective = self.effective_permissions(id); - if !effective.any_allowed() { - return Err(format!("AI access denied for connection '{id}'")); - } let actions = required_permissions(cap, args)?; let requires_confirmation = matches!(cap.risk_level, RiskLevel::Destructive) || actions.contains(&Permission::Delete) || actions.contains(&Permission::Manage); for action in actions { if effective.get(action) != Some(true) { - return Err(format!( - "{action:?} access denied for connection '{id}' by AI permissions" - )); + let source = if self + .connection_permissions + .get(id) + .and_then(|rules| rules.get(action)) + == Some(false) + { + "connection" + } else { + "system" + }; + return Err(AccessDenial::permission(action, id, source).encode()); } if let Some(rules) = restriction { if rules.get(action) == Some(false) || (action == Permission::Manage && rules.manage != Some(true)) { - return Err(format!( - "{action:?} access denied by conversation permissions" - )); + return Err(AccessDenial::permission(action, id, "conversation").encode()); } } } diff --git a/src-tauri/src/ai_permissions/denial.rs b/src-tauri/src/ai_permissions/denial.rs new file mode 100644 index 00000000..dffdbbde --- /dev/null +++ b/src-tauri/src/ai_permissions/denial.rs @@ -0,0 +1,105 @@ +use super::chain::Permission; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AccessDenial { + pub kind: String, + pub source: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub connection_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub action: Option, +} + +impl AccessDenial { + pub fn permission(action: Permission, id: &str, source: &str) -> Self { + let source_label = match source { + "system" => "SQLKit system AI defaults", + "connection" => "this connection's AI permissions", + "conversation" => "this conversation's source restrictions", + _ => "SQLKit AI permissions", + }; + Self { + kind: "permission".into(), + source: source.into(), + message: format!("{action:?} access denied for connection '{id}' by {source_label}"), + connection_id: Some(id.into()), + source_id: None, + action: Some(action), + } + } + + pub fn rule() -> Self { + Self::other("rule", "rule", "Tool call denied by conversation rule") + } + + pub fn other(kind: &str, source: &str, message: &str) -> Self { + Self { + kind: kind.into(), + source: source.into(), + message: message.into(), + connection_id: None, + source_id: None, + action: None, + } + } + + pub fn encode(&self) -> String { + serde_json::json!({"aiAccessDenial": self}).to_string() + } + + pub fn decode(message: &str) -> Option { + let value: Value = serde_json::from_str(message).ok()?; + serde_json::from_value(value.get("aiAccessDenial")?.clone()).ok() + } + + pub fn from_message(message: &str) -> Self { + Self::decode(message).unwrap_or_else(|| { + let user = message == "Tool call was not approved" + || (message.starts_with("Tool call '") + && message.contains("was denied by the user.")); + Self::other(if user { "user" } else { "permission" }, "unknown", message) + }) + } + + pub fn from_error(message: &str) -> Self { + Self::decode(message).unwrap_or_else(|| Self::other("unavailable", "unknown", message)) + } + + pub fn with_context(mut self, config: &Value) -> Self { + self.connection_id = self + .connection_id + .or_else(|| config["connectionId"].as_str().map(str::to_owned)); + self.source_id = config["sourceId"].as_str().map(str::to_owned); + self + } +} + +pub(crate) fn display_denial(message: &str) -> String { + AccessDenial::decode(message).map_or_else(|| message.to_owned(), |denial| denial.message) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn denial_context_survives_runtime_strings_without_exposing_json_to_mcp() { + let denial = AccessDenial::permission(Permission::Manage, "db", "system") + .with_context(&serde_json::json!({"connectionId":"db","sourceId":"source"})); + let encoded = denial.encode(); + assert_eq!(AccessDenial::decode(&encoded), Some(denial.clone())); + assert_eq!(display_denial(&encoded), denial.message); + assert_eq!(denial.source_id.as_deref(), Some("source")); + assert!(AccessDenial::decode("ordinary database error").is_none()); + assert_eq!( + AccessDenial::from_error("Invalid SQL statement").kind, + "unavailable" + ); + } +} diff --git a/src-tauri/src/ai_permissions/invocation.rs b/src-tauri/src/ai_permissions/invocation.rs index 7d39e864..a91829a3 100644 --- a/src-tauri/src/ai_permissions/invocation.rs +++ b/src-tauri/src/ai_permissions/invocation.rs @@ -114,7 +114,11 @@ pub(crate) fn prepare_agent_call( args: &Value, config: &Value, ) -> Result { - let authorization = authorize(name, args, config, Some(access))?; + let authorization = authorize(name, args, config, Some(access)).map_err(|error| { + super::AccessDenial::from_error(&error) + .with_context(config) + .encode() + })?; let mut config = if config.is_object() { config.clone() } else { @@ -146,7 +150,16 @@ pub(crate) async fn invoke_ai( config: &Value, access: Option<&AgentAccess>, ) -> Result { - let initial = authorize(name, args, config, access).map_err(ToolExecutionError::Denied)?; + let denial = |error: String| { + ToolExecutionError::Denied(if access.is_some() { + super::AccessDenial::from_error(&error) + .with_context(config) + .encode() + } else { + super::display_denial(&error) + }) + }; + let initial = authorize(name, args, config, access).map_err(&denial)?; if access.is_some() { check_confirmation(config, initial.confirmation)?; } @@ -155,8 +168,7 @@ pub(crate) async fn invoke_ai( None => None, }; // Resolution can await connection/SSH setup. Revalidate at dispatch after it completes. - let authorization = - authorize(name, args, config, access).map_err(ToolExecutionError::Denied)?; + let authorization = authorize(name, args, config, access).map_err(&denial)?; if access.is_some() { check_confirmation(config, authorization.confirmation)?; } @@ -246,7 +258,45 @@ pub async fn get_ai_context( schemas.push(format!("-- Source: {alias}\n{schema}")); } } - Ok(json!({"readableConnectionIds": readable, "schema": schemas.join("\n")})) + let policy = load_permission_policy(&app)?; + let session = access.current("")?; + let effective_permissions = + connection_permission_context(&policy, &access.connections, &session.restrictions); + Ok( + json!({"readableConnectionIds": readable, "schema": schemas.join("\n"), "effectivePermissions": effective_permissions}), + ) +} + +fn connection_permission_context( + policy: &AiPolicy, + connections: &std::collections::HashMap, + restrictions: &std::collections::HashMap, +) -> std::collections::HashMap { + connections + .iter() + .filter_map(|(alias, config)| { + let id = config["connectionId"].as_str()?; + let restriction = restrictions.get(id)?; + let effective = policy.effective_permissions(id); + let allowed = |action| { + effective.get(action) == Some(true) + && restriction.get(action) != Some(false) + && (action != super::chain::Permission::Manage + || restriction.manage == Some(true)) + }; + use super::chain::Permission::{Create, Delete, Manage, Read, Update}; + Some(( + alias.clone(), + super::PermissionRules::explicit( + allowed(Read), + allowed(Create), + allowed(Update), + allowed(Delete), + allowed(Manage), + ), + )) + }) + .collect() } #[cfg(test)] @@ -282,6 +332,53 @@ mod tests { ..Default::default() } } + + #[test] + fn persisted_connection_grant_refreshes_retry_context_and_still_requires_ddl_approval() { + let dir = tempfile::tempdir().unwrap(); + let mut config = crate::mcp_bridge::McpConfig::default(); + config.policy.permission_defaults = + Some(PermissionRules::explicit(true, true, true, true, false)); + config.save(dir.path()).unwrap(); + let target = json!({"connectionId":"42"}); + let connections = HashMap::from([ + ("example".into(), target.clone()), + ("outside".into(), json!({"connectionId":"99"})), + ]); + let mut ddl = cap(); + ddl.name = "sqlkit__execute_ddl"; + ddl.required_permission = "manage"; + ddl.risk_level = RiskLevel::Destructive; + let args = json!({"sql":"CREATE TABLE example(id INT)"}); + assert!(evaluate(config.policy.clone(), &ddl, &args, &target, Some(session())).is_err()); + config.policy.connection_permissions.insert( + "42".into(), + PermissionRules::explicit(true, true, true, true, true), + ); + config.save(dir.path()).unwrap(); + let current = super::super::read_permission_config(dir.path()) + .unwrap() + .policy; + assert_eq!( + current.permission_defaults.as_ref().unwrap().manage, + Some(false) + ); + let decision = evaluate(current.clone(), &ddl, &args, &target, Some(session())).unwrap(); + assert!(decision.confirmation); + let context = + connection_permission_context(¤t, &connections, &session().restrictions); + assert_eq!(context["example"].manage, Some(true)); + assert!(!context.contains_key("outside")); + let mut restricted = session(); + restricted.restrictions.get_mut("42").unwrap().manage = Some(false); + assert_eq!( + connection_permission_context(¤t, &connections, &restricted.restrictions) + ["example"] + .manage, + Some(false) + ); + assert!(evaluate(current, &ddl, &args, &target, Some(restricted)).is_err()); + } fn session() -> SessionAccess { SessionAccess { restrictions: HashMap::from([( @@ -356,6 +453,32 @@ mod tests { assert!(check_confirmation(&json!({"__ai_confirmation_required":true}), true).is_ok()); } #[test] + fn denials_identify_the_decision_source_without_changing_precedence() { + let config = json!({"connectionId":"42"}); + let args = json!({"sql":"UPDATE t SET x=1"}); + let mut system = policy(); + system.permission_defaults.as_mut().unwrap().update = Some(false); + let denied_source = |policy, session| { + let error = evaluate(policy, &cap(), &args, &config, Some(session)) + .err() + .unwrap(); + super::super::AccessDenial::decode(&error).unwrap().source + }; + assert_eq!(denied_source(system, session()), "system"); + let mut connection = policy(); + connection.connection_permissions.insert( + "42".into(), + PermissionRules { + update: Some(false), + ..Default::default() + }, + ); + assert_eq!(denied_source(connection, session()), "connection"); + let mut conversation = session(); + conversation.restrictions.get_mut("42").unwrap().update = Some(false); + assert_eq!(denied_source(policy(), conversation), "conversation"); + } + #[test] fn discovery_is_limited_to_current_scope_and_effective_actions() { let mut cap = cap(); cap.name = "app__list_connections"; diff --git a/src-tauri/src/ai_permissions/mod.rs b/src-tauri/src/ai_permissions/mod.rs index 51898c8c..d021e3a1 100644 --- a/src-tauri/src/ai_permissions/mod.rs +++ b/src-tauri/src/ai_permissions/mod.rs @@ -1,10 +1,14 @@ mod chain; +mod denial; pub(crate) mod invocation; mod policy; mod scope; +pub(crate) use denial::{display_denial, AccessDenial}; pub(crate) use invocation::{invoke_ai, prepare_agent_call}; pub(crate) use scope::AgentAccess; +#[cfg(test)] +pub(crate) use chain::Permission; pub(crate) use chain::PermissionRules; pub(crate) use policy::AiPolicy; #[cfg(test)] @@ -67,6 +71,41 @@ pub fn get_ai_permissions(app: AppHandle) -> Result { load_permission_policy(&app) } +fn authorize_permission_change( + access: Result<(), String>, + defaults: &Option, + connection_id: Option<&str>, + permissions: &Option, + saved_connections: impl FnOnce() -> Result, String>, +) -> Result<(), String> { + if access.is_ok() + || defaults.is_some() + || permissions + .as_ref() + .is_some_and(|rules| *rules != PermissionRules::default()) + { + return access; + } + let Some(id) = connection_id else { + return access; + }; + match saved_connections()? { + None => Ok(()), + Some(Value::Array(connections)) + if connections + .iter() + .all(|connection| match connection.get("id") { + Some(Value::String(saved)) => saved != id, + Some(Value::Number(saved)) => saved.to_string() != id, + _ => false, + }) => + { + Ok(()) + } + _ => access, + } +} + #[tauri::command] pub fn save_ai_permissions( defaults: Option, @@ -74,6 +113,19 @@ pub fn save_ai_permissions( permissions: Option, app: AppHandle, ) -> Result { + authorize_permission_change( + crate::entitlement::ensure_local_ultimate( + &app.state::(), + "AI permissions", + ), + &defaults, + connection_id.as_deref(), + &permissions, + || { + let store = app.store(".store.dat").map_err(|e| e.to_string())?; + Ok(store.get("connections")) + }, + )?; let _guard = AI_POLICY_LOCK.lock().map_err(|e| e.to_string())?; let mut policy = load_permission_policy_inner(&app)?; if let Some(defaults) = defaults { @@ -93,3 +145,111 @@ pub fn save_ai_permissions( config.save(&dir)?; Ok(policy) } + +#[cfg(test)] +mod entitlement_tests { + use super::*; + use serde_json::json; + + fn denied() -> Result<(), String> { + Err(crate::entitlement::entitlement_required_error( + "AI permissions", + )) + } + + #[test] + fn ultimate_can_edit_defaults_and_connection_decisions_without_cleanup_lookup() { + assert!(authorize_permission_change( + Ok(()), + &Some(PermissionRules::default()), + Some("saved"), + &Some(PermissionRules::explicit(true, false, false, false, false)), + || panic!("editing must not use the cleanup path"), + ) + .is_ok()); + } + + #[test] + fn community_cannot_edit_defaults_or_explicit_connection_decisions() { + assert!(authorize_permission_change( + denied(), + &Some(PermissionRules::default()), + None, + &None, + || panic!("denied defaults must not inspect storage"), + ) + .is_err()); + assert!(authorize_permission_change( + denied(), + &None, + Some("saved"), + &Some(PermissionRules::explicit(false, false, false, false, false)), + || panic!("explicit denials are permission edits, not cleanup"), + ) + .is_err()); + assert!(authorize_permission_change( + denied(), + &None, + Some("new"), + &Some(PermissionRules::explicit(true, false, false, false, false)), + || panic!("new connection permissions also require Ultimate"), + ) + .is_err()); + } + + #[test] + fn community_cannot_reset_a_saved_connections_permissions() { + for (id, connections) in [ + ("saved", json!([{"id":"saved"}])), + ("42", json!([{"id":42}])), + ] { + let error = authorize_permission_change( + denied(), + &None, + Some(id), + &Some(PermissionRules::default()), + || Ok(Some(connections)), + ) + .unwrap_err(); + assert!(error.contains("ENTITLEMENT_REQUIRED")); + } + } + + #[test] + fn community_can_only_cleanup_decisions_for_deleted_connections() { + assert!(authorize_permission_change( + denied(), + &None, + Some("deleted"), + &Some(PermissionRules::default()), + || Ok(Some(json!([{"id":"retained"}]))), + ) + .is_ok()); + assert!( + authorize_permission_change(denied(), &None, Some("last-deleted"), &None, || Ok(None),) + .is_ok() + ); + } + + #[test] + fn unreadable_or_malformed_connections_do_not_authorize_cleanup() { + for connections in [ + json!({}), + json!([{"name":"missing-id"}]), + json!([{"id":null}]), + ] { + assert!( + authorize_permission_change(denied(), &None, Some("saved"), &None, || Ok(Some( + connections + )),) + .is_err() + ); + } + assert!( + authorize_permission_change(denied(), &None, Some("saved"), &None, || Err( + "Storage unavailable".into() + ),) + .is_err() + ); + } +} diff --git a/src-tauri/src/ai_permissions/scope.rs b/src-tauri/src/ai_permissions/scope.rs index ae0a6da0..c9d52f3d 100644 --- a/src-tauri/src/ai_permissions/scope.rs +++ b/src-tauri/src/ai_permissions/scope.rs @@ -110,7 +110,7 @@ fn intersect(a: &PermissionRules, b: &PermissionRules) -> PermissionRules { impl SessionAccess { pub fn confirmation_required(&self, risk: RiskLevel) -> Result { match self.rule.as_deref() { - Some("deny") => Err("Tool call denied by conversation rule".into()), + Some("deny") => Err(super::AccessDenial::rule().encode()), Some("allow") => Ok(false), _ => Ok(match risk { RiskLevel::Safe => false, diff --git a/src-tauri/src/mcp_bridge.rs b/src-tauri/src/mcp_bridge.rs index 725469e6..d0ee9c76 100644 --- a/src-tauri/src/mcp_bridge.rs +++ b/src-tauri/src/mcp_bridge.rs @@ -398,7 +398,7 @@ async fn invoke_with_policy(policy: &AiPolicy, payload: InvokeRequest) -> Invoke } else { check_policy(cap, policy, payload.connection_id.as_deref()) } { - return InvokeResponse::error(403, msg); + return InvokeResponse::error(403, crate::ai_permissions::display_denial(&msg)); } let config = payload diff --git a/src/__tests__/SshTunnelConfigPanel.test.ts b/src/__tests__/SshTunnelConfigPanel.test.ts index a2b3666a..97c1732b 100644 --- a/src/__tests__/SshTunnelConfigPanel.test.ts +++ b/src/__tests__/SshTunnelConfigPanel.test.ts @@ -6,11 +6,13 @@ * SshTunnelConfigPanel — configure-as-intent journey tests. * * The tunnel has no explicit enable toggle: adding a hop / attaching the - * system proxy / typing inline fields IS the intent (enabled is derived in + * system proxy / saving inline settings IS the intent (enabled is derived in * patch()), mirroring the sibling NoSQL client. These tests pin that semantic. */ import { flushPromises, mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' +import Sortable from 'sortablejs' +import SshProfileDialog from '@/components/connections/SshProfileDialog.vue' import SshTunnelConfigPanel from '@/components/connections/SshTunnelConfigPanel.vue' jest.mock('@tauri-apps/api/core', () => ({ @@ -41,6 +43,8 @@ jest.mock('sortablejs', () => ({ default: { create: jest.fn(() => ({ destroy: jest.fn() })) }, })) +const mockSaveProfile = jest.fn(async (profile: { id: string, name: string }) => ({ ...profile, id: profile.id || 'new-1' })) + jest.mock('@/store/sshProfileStore', () => ({ emptySshProfile: () => ({ id: '', @@ -65,7 +69,7 @@ jest.mock('@/store/sshProfileStore', () => ({ byId: (id: string) => HOP_PROFILES.find(p => p.id === id), // eslint-disable-next-line ts/no-use-before-define profiles: HOP_PROFILES, - save: jest.fn(async (profile: { id: string, name: string }) => ({ ...profile, id: profile.id || 'new-1' })), + save: mockSaveProfile, }), profileToTransportLayer: (profile: { host: string, port: number, username: string }) => ({ type: 'ssh', @@ -90,7 +94,8 @@ jest.mock('@/store/entitlementStore', () => ({ })) jest.mock('@/components/connections/SshProfileDialog.vue', () => ({ - default: { template: '', methods: { show: jest.fn() } }, + __esModule: true, + default: { name: 'SshProfileDialog', template: '', methods: { show: jest.fn() } }, })) jest.mock('@/components/ui/button', () => ({ Button: { template: '' } })) @@ -102,6 +107,14 @@ jest.mock('@/components/ui/input', () => ({ template: '', }, })) +jest.mock('@/components/ui/dialog', () => ({ + Dialog: { props: ['open'], template: '
' }, + DialogContent: { template: '
' }, + DialogHeader: { template: '
' }, + DialogTitle: { template: '

' }, + DialogDescription: { template: '

' }, + DialogFooter: { template: '
' }, +})) jest.mock('@/components/ui/select', () => ({ Select: { template: '' }, SelectContent: { template: '' }, @@ -112,9 +125,10 @@ jest.mock('@/components/ui/select', () => ({ })) jest.mock('@/components/ui/combobox', () => ({ SearchableSelect: { - props: ['modelValue', 'options', 'placeholder', 'searchThreshold'], + name: 'SearchableSelect', + props: ['modelValue', 'options', 'placeholder', 'searchThreshold', 'disabled'], emits: ['update:modelValue'], - template: '', + template: '', }, })) jest.mock('@/components/upgrade', () => ({ ProBadge: { template: '' } })) @@ -154,6 +168,7 @@ async function syncProps(wrapper: ReturnType) { describe('sshTunnelConfigPanel — configure-as-intent', () => { beforeEach(() => { + jest.clearAllMocks() setActivePinia(createPinia()) }) @@ -228,22 +243,131 @@ describe('sshTunnelConfigPanel — configure-as-intent', () => { expect(payload.enabled).toBe(true) }) - it('legacy inline connections keep editing fields and stay enabled', async () => { - const wrapper = mountPanel({ enabled: true, host: 'jump.example.com', port: 22, username: 'ops' }) - expect(wrapper.text()).toContain('components.serverForm.ssh.legacyManual') + const legacyConfig = { + enabled: true, + host: 'jump.example.com', + port: 2222, + username: 'ops', + authMethod: 'password', + password: 'synthetic-password', + useSystemProxy: true, + } - const hostInput = wrapper.find('#ssh-host') - expect(hostInput.exists()).toBe(true) - await hostInput.setValue('new-jump.example.com') + it('shows a compact inline tunnel without converting or revealing credentials', async () => { + const wrapper = mountPanel(legacyConfig) + await flushPromises() + expect(wrapper.find('#ssh-host').exists()).toBe(false) + expect(wrapper.text()).toContain('ops@jump.example.com:2222') + expect(wrapper.text()).not.toContain('synthetic-password') + expect(mockSaveProfile).not.toHaveBeenCalled() + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(wrapper.find('.stub-combobox').attributes('disabled')).toBeDefined() + wrapper.findComponent({ name: 'SearchableSelect' }).vm.$emit('update:modelValue', 'p1') + await wrapper.vm.$nextTick() + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) - const payload = lastPayload(wrapper) - expect(payload.host).toBe('new-jump.example.com') - expect(payload.enabled).toBe(true) + it('edits tunnel details in a draft and keeps Cancel separate from Save', async () => { + const wrapper = mountPanel(legacyConfig) + const edit = () => wrapper.get('button[title="components.serverForm.ssh.editTunnel"]') + await edit().trigger('click') + expect(wrapper.find('[role="dialog"]').exists()).toBe(true) + await wrapper.get('#ssh-host').setValue('cancelled.example.com') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + await wrapper.get('footer button').trigger('click') + expect(wrapper.find('#ssh-host').exists()).toBe(false) + await edit().trigger('click') + expect((wrapper.get('#ssh-host').element as HTMLInputElement).value).toBe('jump.example.com') + await wrapper.get('#ssh-host').setValue('new-jump.example.com') + await wrapper.findAll('footer button')[1]!.trigger('click') + expect(lastPayload(wrapper)).toMatchObject({ ...legacyConfig, host: 'new-jump.example.com' }) + expect(mockSaveProfile).not.toHaveBeenCalled() + expect(wrapper.find('#ssh-host').exists()).toBe(false) + }) + + it.each(['password', 'privateKey', 'agent'])('converts %s only on click and preserves authentication and proxy', async (authMethod) => { + const config = { ...legacyConfig, authMethod, privateKey: '/synthetic/key', privateKeyPassphrase: 'synthetic-passphrase' } + const wrapper = mountPanel(config) + await flushPromises() + const convert = wrapper.findAll('button').find(b => b.text() === 'components.serverForm.ssh.convertToProfile')! + await convert.trigger('click') + await flushPromises() + expect(mockSaveProfile).toHaveBeenCalledTimes(1) + expect(mockSaveProfile).toHaveBeenCalledWith(expect.objectContaining({ + host: config.host, + port: config.port, + username: config.username, + authMethod: authMethod === 'privateKey' ? 'key' : authMethod, + password: config.password, + keyPath: config.privateKey, + keyPassphrase: config.privateKeyPassphrase, + useSshAgent: authMethod === 'agent', + })) + expect(lastPayload(wrapper)).toMatchObject({ + enabled: true, + profileIds: ['new-1'], + useSystemProxy: true, + host: '', + username: '', + password: '', + privateKey: '', + privateKeyPassphrase: '', + }) + }) + + it('keeps inline settings on conversion failure and allows a retry', async () => { + mockSaveProfile.mockRejectedValueOnce(new Error('Profile storage unavailable')) + const wrapper = mountPanel(legacyConfig) + await flushPromises() + const convert = wrapper.findAll('button').find(b => b.text() === 'components.serverForm.ssh.convertToProfile')! + await convert.trigger('click') + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toContain('Profile storage unavailable') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + await convert.trigger('click') + await flushPromises() + expect(lastPayload(wrapper).profileIds).toEqual(['new-1']) + }) + + it('detaches the inline tunnel while retaining its attached proxy', async () => { + const wrapper = mountPanel(legacyConfig) + await wrapper.get('button[title="components.serverForm.ssh.detachAction"]').trigger('click') + expect(lastPayload(wrapper)).toMatchObject({ enabled: true, useSystemProxy: true, host: '', password: '' }) + }) + + it('reorders saved hops with keyboard-accessible buttons', async () => { + const wrapper = mountPanel({ enabled: true, profileIds: ['p1', 'p2'] }) + await wrapper.findAll('button[title="components.serverForm.ssh.moveDown"]')[0]!.trigger('click') + await syncProps(wrapper) + expect(lastPayload(wrapper).profileIds).toEqual(['p2', 'p1']) + expect(wrapper.findAll('.ssh-hop-handle').map(el => el.element.parentElement?.textContent)).toEqual([ + expect.stringContaining('Bastion B'), + expect.stringContaining('Bastion A'), + ]) + }) + + it('restores the sortable DOM before applying a drag reorder and reinitializes after detach', async () => { + const wrapper = mountPanel({ enabled: true, profileIds: ['p1', 'p2'] }) + await flushPromises() + const create = Sortable.create as jest.Mock + const options = create.mock.calls[create.mock.calls.length - 1]![1] + const rows = wrapper.findAll('.ssh-hop-handle').map(el => el.element.parentElement!) + rows[0]!.parentElement!.appendChild(rows[0]!) + options.onEnd({ item: rows[0], oldIndex: 0, newIndex: 1 }) + await syncProps(wrapper) + expect(lastPayload(wrapper).profileIds).toEqual(['p2', 'p1']) + expect(wrapper.findAll('.ssh-hop-handle')[0]!.element.parentElement?.textContent).toContain('Bastion B') + const instance = create.mock.results[create.mock.results.length - 1]!.value + await wrapper.setProps({ modelValue: { enabled: false, profileIds: [] } as never }) + expect(instance.destroy).toHaveBeenCalled() + const previous = create.mock.calls.length + await wrapper.setProps({ modelValue: { enabled: true, profileIds: ['p1'] } as never }) + expect(create.mock.calls.length).toBe(previous + 1) }) it('saving a NEW profile appends it to the chain', async () => { const wrapper = mountPanel({ enabled: true, profileIds: ['p1'] }) - await wrapper.findComponent({ name: 'SshProfileDialog' })?.vm?.$emit('save', { id: 'p2', name: 'Bastion B' }) + await wrapper.getComponent(SshProfileDialog).vm.$emit('save', { id: 'p2', name: 'Bastion B' }) await wrapper.vm.$nextTick() expect(lastPayload(wrapper).profileIds).toEqual(['p1', 'p2']) @@ -252,7 +376,7 @@ describe('sshTunnelConfigPanel — configure-as-intent', () => { it('saving an EXISTING hop does not duplicate it', async () => { const wrapper = mountPanel({ enabled: true, profileIds: ['p1'] }) const before = wrapper.emitted('update:modelValue')?.length ?? 0 - await wrapper.findComponent({ name: 'SshProfileDialog' })?.vm?.$emit('save', { id: 'p1', name: 'Bastion A' }) + await wrapper.getComponent(SshProfileDialog).vm.$emit('save', { id: 'p1', name: 'Bastion A' }) await wrapper.vm.$nextTick() // No state change → no new emit; the chain keeps exactly one hop. diff --git a/src/__tests__/agentHistory.test.ts b/src/__tests__/agentHistory.test.ts new file mode 100644 index 00000000..d719d57a --- /dev/null +++ b/src/__tests__/agentHistory.test.ts @@ -0,0 +1,66 @@ +import { createPinia, setActivePinia } from 'pinia' +import { agentApi } from '@/datasources/agentApi' +import { useDataStudioStore } from '@/store/dataStudioStore' + +jest.mock('@/datasources/agentApi', () => ({ agentApi: { loadSessionMessages: jest.fn(), loadAgentSessions: jest.fn() } })) + +const denial = { kind: 'permission' as const, source: 'connection' as const, connectionId: 'db', action: 'manage' as const, message: 'Manage denied' } +const rows = [ + { + id: 'assistant', + session_id: 'chat', + role: 'assistant', + created_at: '2026-10-27T00:00:00Z', + content: JSON.stringify({ content: '', tool_calls: [] }), + tool_calls: [ + { id: 'success', tool_name: 'query', arguments: '{}', status: 'completed', result: 'One row' }, + { id: 'failure', tool_name: 'write', arguments: '{}', status: 'failed', result: 'Database error' }, + { id: 'blocked', tool_name: 'ddl', arguments: '{}', status: 'denied', result: denial.message, denial }, + { id: 'rejected', tool_name: 'delete', arguments: '{}', status: 'denied', result: 'Rejected', denial: { ...denial, kind: 'user' as const } }, + { id: 'unfinished', tool_name: 'write', arguments: '{}', status: 'approved' }, + ], + }, + { id: 'tool-result', session_id: 'chat', role: 'tool', created_at: '2026-10-27T00:00:01Z', content: JSON.stringify({ tool_call_id: 'blocked', content: denial.message }) }, +] + +describe('agent history outcomes', () => { + beforeEach(() => { + setActivePinia(createPinia()) + jest.mocked(agentApi.loadSessionMessages).mockResolvedValue(rows) + jest.mocked(agentApi.loadAgentSessions).mockResolvedValue([{ + id: 'chat', + title: 'Chat', + status: 'blocked', + sources: '[]', + permissions_mode: 'Auto', + model_id: '', + created_at: '2026-10-27T00:00:00Z', + updated_at: '2026-10-27T00:00:01Z', + }]) + }) + + it('restores successes, failures, rejections and blocked state after reopening', async () => { + const store = useDataStudioStore() + await store.loadSessions() + const session = store.sessions[0] + expect(session.status).toBe('blocked') + expect(session.messages[0].toolCalls?.map(call => call.status)).toEqual(['done', 'error', 'denied', 'denied', 'interrupted']) + expect(session.messages[0].toolCalls?.[2]).toMatchObject({ result: denial.message, denial, requiresConfirmation: false }) + expect(session.messages[0].toolCalls?.[3].denial?.kind).toBe('user') + expect(session.messages[1].toolCallId).toBe('blocked') + expect(session.messages[1].content).toBe(denial.message) + await store.reloadSessionMessages('chat') + expect(store.sessions[0].messages[0].toolCalls?.[2].denial).toEqual(denial) + }) + + it('never presents a legacy call with no recorded outcome as successful', async () => { + jest.mocked(agentApi.loadSessionMessages).mockResolvedValue([{ + ...rows[0], + tool_calls: undefined, + content: JSON.stringify({ content: '', tool_calls: [{ id: 'unknown', function: { name: 'write', arguments: '{}' } }] }), + }]) + const store = useDataStudioStore() + await store.loadSessions() + expect(store.sessions[0].messages[0].toolCalls?.[0].status).toBe('interrupted') + }) +}) diff --git a/src/__tests__/agentOutcomes.test.ts b/src/__tests__/agentOutcomes.test.ts new file mode 100644 index 00000000..ab85b592 --- /dev/null +++ b/src/__tests__/agentOutcomes.test.ts @@ -0,0 +1,88 @@ +/** @jest-environment jsdom */ +import type { AgentToolCall } from '@/store/dataStudioStore' +import { mount } from '@vue/test-utils' +import AgentMessageBubble from '@/components/agent-message-bubble.vue' +import { enUS } from '@/lang/enUS' +import { zhCN } from '@/lang/zhCN' + +let mockMessages: Record = enUS +jest.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string, values: Record = {}) => { + const text = key.split('.').reduce((value, key) => value && typeof value === 'object' ? (value as Record)[key] : undefined, mockMessages) + return typeof text === 'string' ? text.replace(/\{(\w+)\}/g, (_match, name) => values[name] ?? name) : key + } }), +})) + +jest.mock('@/components/markdown-render.vue', () => ({ template: '
' })) +jest.mock('@/store/dataStudioStore', () => ({ useDataStudioStore: () => ({ toolResultFullBodies: {} }) })) +jest.mock('@/store/connectionStore', () => ({ useConnectionStore: () => ({ getConnectionById: () => ({ name: 'Development database' }) }) })) +jest.mock('@/components/ui/button', () => ({ Button: { template: '' } })) + +const call: AgentToolCall = { + id: 'call', + toolName: 'sqlkit__execute_ddl', + args: '{"connection_id":"dev","sql":"CREATE TABLE example(id INT)"}', + status: 'denied', + riskLevel: 'destructive', + requiresConfirmation: false, + result: 'Manage denied', + denial: { kind: 'permission', source: 'connection', connectionId: 'db', action: 'manage', message: 'Manage denied' }, +} +const render = (tool = call, locale = 'enUS', canRetry = true) => { + mockMessages = locale === 'zhCN' ? zhCN : enUS + return mount(AgentMessageBubble, { + props: { message: { role: 'assistant', status: 'done', toolCalls: [tool] }, canRetry }, + }) +} + +describe('tool outcome notices', () => { + it('keeps blocked action, connection, required permission and source visible outside details', async () => { + const wrapper = render() + const notice = wrapper.get('[role="alert"]') + expect(notice.text()).toContain('Blocked by permissions — action not executed') + expect(notice.text()).toContain('Development database') + expect(notice.text()).toContain('Required:') + expect(notice.text()).toContain('connection’s AI permissions') + expect(wrapper.get('.tool-verb-label').text()).toBe('Blocked') + expect(notice.element.closest('details')).toBeNull() + expect(wrapper.get('.activity-item-details').attributes('open')).toBeUndefined() + expect(wrapper.find('.result-preview-denied').exists()).toBe(false) + await notice.findAll('button')[0].trigger('click') + expect(wrapper.emitted('reviewPermissions')?.[0]).toEqual([call]) + await notice.findAll('button')[1].trigger('click') + expect(wrapper.emitted('retryToolCall')?.[0]).toEqual([call]) + }) + + it('labels an earlier denial as history after the user sends a new request', async () => { + const wrapper = render() + await wrapper.setProps({ historical: true }) + expect(wrapper.get('[role="alert"]').text()).toContain('Previous attempt blocked') + }) + + it('distinguishes user rejection without offering to change permissions', () => { + const wrapper = render({ ...call, denial: { ...call.denial!, kind: 'user' } }) + expect(wrapper.get('[role="alert"]').text()).toContain('Rejected by you — action not executed') + expect(wrapper.get('.tool-verb-label').text()).toBe('Rejected') + expect(wrapper.text()).not.toContain('Review permissions') + }) + + it('localizes rejection and disables recovery while the previous run is still settling', () => { + const wrapper = render(call, 'zhCN', false) + expect(wrapper.get('[role="alert"]').text()).toContain('权限不足,操作未执行') + expect(wrapper.findAll('button').every(button => button.attributes('disabled') !== undefined)).toBe(true) + }) + + it.each(['error', 'interrupted'] as const)('never labels a %s call as executed', (status) => { + const wrapper = render({ ...call, status, denial: undefined }) + expect(wrapper.find('[role="alert"]').exists()).toBe(true) + expect(wrapper.get('.tool-verb-label').text()).not.toContain('Executed') + expect(wrapper.find('.activity-status-icon.done').exists()).toBe(false) + }) + + it('does not mislabel unavailable sources or invalid requests as permission failures', () => { + const wrapper = render({ ...call, denial: { kind: 'unavailable', source: 'unknown', message: 'Saved connection is no longer available' } }) + expect(wrapper.get('[role="alert"]').text()).toContain('Action unavailable — action not executed') + expect(wrapper.get('[role="alert"]').text()).toContain('Saved connection is no longer available') + expect(wrapper.text()).not.toContain('Review permissions') + }) +}) diff --git a/src/__tests__/agentPermissionRecovery.test.ts b/src/__tests__/agentPermissionRecovery.test.ts new file mode 100644 index 00000000..7b8f97ef --- /dev/null +++ b/src/__tests__/agentPermissionRecovery.test.ts @@ -0,0 +1,105 @@ +/** @jest-environment jsdom */ +import { flushPromises, mount } from '@vue/test-utils' +import { reactive } from 'vue' +import { saveAiPermissions } from '@/common' +import ToolPermissionReview from '@/components/ai-permissions/ToolPermissionReview.vue' +import { agentApi } from '@/datasources/agentApi' + +const mockRemoveRule = jest.fn().mockResolvedValue(undefined) +const mockConnection = jest.fn() +const mockEntitlement = reactive({ isLocalUltimate: true }) +jest.mock('@/common', () => ({ saveAiPermissions: jest.fn() })) +jest.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +jest.mock('@/store/dataStudioStore', () => ({ useDataStudioStore: () => ({ activeSession: { id: 'chat', sources: [] }, removeConfirmationRule: mockRemoveRule }) })) +jest.mock('@/datasources/agentApi', () => ({ agentApi: { loadConfirmationRules: jest.fn() } })) +jest.mock('@/store', () => ({ useConnectionStore: () => ({ getConnectionById: mockConnection }), useEntitlementStore: () => mockEntitlement })) +jest.mock('@/components/ui/button', () => ({ Button: { template: '' } })) +jest.mock('@/components/ui/dialog', () => Object.fromEntries(['Dialog', 'DialogContent', 'DialogDescription', 'DialogFooter', 'DialogHeader', 'DialogTitle'].map(name => [name, { template: '
' }]))) +jest.mock('@/views/data-studio/components/modify-source-modal.vue', () => ({ __esModule: true, default: { template: '
' } })) +jest.mock('@/components/ai-permissions/ConnectionPermissionEditor.vue', () => ({ + __esModule: true, + default: { + props: ['connectionId'], + emits: ['update:modelValue'], + data: () => ({ ready: true }), + mounted() { (this as any).$emit('update:modelValue', { manage: true }) }, + template: '
Connection {{ connectionId }}
', + }, +})) + +const denial = { kind: 'permission' as const, source: 'connection' as const, connectionId: 'db', action: 'manage' as const, message: 'Manage denied' } +const openReview = (source = denial.source as 'connection' | 'system' | 'rule') => mount(ToolPermissionReview, { + props: { open: true, denial: { ...denial, source }, toolName: 'ddl', sessionId: 'chat' }, +}) + +describe('permission recovery', () => { + beforeEach(() => { + jest.clearAllMocks() + mockConnection.mockReturnValue({ id: 'db', name: 'Development database' }) + mockEntitlement.isLocalUltimate = true + jest.mocked(saveAiPermissions).mockResolvedValue({ permission_defaults: {}, connection_permissions: {} }) + }) + + it('saves only the blocked connection and returns without executing the action', async () => { + const wrapper = openReview() + await flushPromises() + expect(wrapper.text()).toContain('Connection db') + expect(wrapper.text()).not.toContain('System defaults') + const save = wrapper.findAll('button').find(button => button.text() === 'dataStudio.modifySource.saveChanges')! + const back = wrapper.findAll('button').find(button => button.text() === 'dataStudio.agent.message.backToChat')! + expect(save.element.parentElement).toBe(back.element.parentElement) + await save.trigger('click') + await flushPromises() + expect(saveAiPermissions).toHaveBeenCalledWith('db', { manage: true }) + expect(wrapper.emitted('update:open')).toEqual([[false]]) + expect(wrapper.emitted('retryToolCall')).toBeUndefined() + }) + + it('edits only the blocked connection when inheritance caused the denial', async () => { + const wrapper = openReview('system') + await flushPromises() + expect(wrapper.text()).toContain('Connection db') + expect(wrapper.text()).not.toContain('System defaults') + expect(wrapper.text()).toContain('dataStudio.agent.message.reviewConnectionHelp') + const save = wrapper.findAll('button').find(button => button.text() === 'dataStudio.modifySource.saveChanges')! + await save.trigger('click') + await flushPromises() + expect(saveAiPermissions).toHaveBeenCalledWith('db', { manage: true }) + }) + + it('never falls back to editing defaults when a connection cannot be identified', () => { + const wrapper = mount(ToolPermissionReview, { props: { open: true, denial: { ...denial, source: 'system', connectionId: undefined }, toolName: 'ddl', sessionId: 'chat' } }) + expect(wrapper.text()).toContain('dataStudio.agent.message.connectionUnavailable') + expect(wrapper.text()).not.toContain('dataStudio.modifySource.saveChanges') + expect(saveAiPermissions).not.toHaveBeenCalled() + }) + + it('does not offer edits for a deleted connection', () => { + mockConnection.mockReturnValue(undefined) + const wrapper = openReview() + expect(wrapper.text()).toContain('dataStudio.agent.message.connectionUnavailable') + expect(wrapper.text()).not.toContain('dataStudio.modifySource.saveChanges') + expect(saveAiPermissions).not.toHaveBeenCalled() + }) + + it('keeps the connection permission entitlement gate on recovery', () => { + mockEntitlement.isLocalUltimate = false + const wrapper = openReview() + expect(wrapper.text()).not.toContain('dataStudio.modifySource.saveChanges') + expect(saveAiPermissions).not.toHaveBeenCalled() + }) + + it('removes only the matching saved deny rule in the current conversation', async () => { + jest.mocked(agentApi.loadConfirmationRules).mockResolvedValue([ + { id: 'other', session_id: 'chat', tool_name: 'read', action: 'deny', created_at: '0' }, + { id: 'target', session_id: 'chat', tool_name: 'ddl', action: 'deny', created_at: '0' }, + ]) + const wrapper = openReview('rule') + await wrapper.findAll('button').find(button => button.text() === 'dataStudio.agent.message.removeDenyRule')!.trigger('click') + await flushPromises() + expect(agentApi.loadConfirmationRules).toHaveBeenCalledWith('chat') + expect(mockRemoveRule).toHaveBeenCalledWith('target') + expect(saveAiPermissions).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('dataStudio.agent.message.ruleRemoved') + }) +}) diff --git a/src/__tests__/agentRetryContext.test.ts b/src/__tests__/agentRetryContext.test.ts new file mode 100644 index 00000000..35c41ee1 --- /dev/null +++ b/src/__tests__/agentRetryContext.test.ts @@ -0,0 +1,58 @@ +import type { ChatSession } from '@/types/chat' +import { computed, reactive, ref } from 'vue' +import { useChatAgent } from '@/composables/useChatAgent' +import { agentApi } from '@/datasources/agentApi' + +jest.mock('@/common', () => jest.requireActual('@/common/agentPrompt')) +jest.mock('@/datasources/agentApi', () => ({ agentApi: { getAvailableTools: jest.fn(), getAiContext: jest.fn(), runAgentLoop: jest.fn() } })) +jest.mock('@/store/connectionStore', () => ({ useConnectionStore: () => ({ getConnectionById: () => undefined }) })) +jest.mock('@/store/entitlementStore', () => ({ useEntitlementStore: () => ({ isLocalUltimate: false }) })) +jest.mock('@/store/appStore', () => ({ useAppStore: () => ({ + llmSettings: { chat: {} }, + getFeatureModelConfig: async () => ({ provider: { name: 'synthetic', apiKey: 'synthetic', apiCompatibility: 'openai-compatible' }, model: { label: 'synthetic' } }), +}) })) +jest.mock('@/store/dataStudioStore', () => ({ useDataStudioStore: () => ({ clearSessionError: jest.fn(), insertPreparingPlaceholder: jest.fn(), setSessionProgress: jest.fn(), getSessionError: () => undefined }) })) + +describe('agent retry uses current backend permissions', () => { + it.each([true, false])('uses current Manage=%s instead of the previous frontend snapshot', async (manage) => { + const session = reactive({ id: 'chat', messages: [], status: 'blocked', maxIterations: 3 }) + jest.mocked(agentApi.getAvailableTools).mockResolvedValue({ tools: [], metadata: {} }) + jest.mocked(agentApi.getAiContext).mockResolvedValue({ + readableConnectionIds: ['42'], + schema: '', + effectivePermissions: { example: { read: true, create: true, update: true, delete: true, manage } }, + }) + jest.mocked(agentApi.runAgentLoop).mockClear() + jest.mocked(agentApi.runAgentLoop).mockResolvedValue(undefined) + const agent = useChatAgent({ + feature: 'dataStudio', + autoMode: ref(true), + sessionStore: { + sessions: ref([session]), + activeSession: computed(() => session), + activeSessionId: ref('chat'), + addMessage: jest.fn(), + updateStreamingContent: jest.fn(), + updateStreamingThinking: jest.fn(), + setMessageStatus: jest.fn(), + setMessageToolCalls: jest.fn(), + removeOrphanedStreamingMessages: jest.fn(), + updateToolCallStatus: jest.fn(), + setSessionStatus: (_id, status) => { session.status = status }, + setSessionSchema: jest.fn(), + clearSession: jest.fn(), + getOrCreateSession: async () => 'chat', + reloadSessionMessages: jest.fn(), + }, + }) + await agent.sendMessage({ content: 'retry', context: { connections: { example: { + connectionId: '42', + dbType: 'POSTGRESQL', + permissions: { read: true, create: false, update: false, delete: false, manage: !manage }, + } } } }) + expect(agentApi.runAgentLoop).toHaveBeenCalledTimes(1) + const settings = jest.mocked(agentApi.runAgentLoop).mock.calls[0][2] as Record + expect(settings.systemPrompt).toContain(manage ? 'allowed actions: read, create, update, delete, manage; denied actions: none' : 'allowed actions: read, create, update, delete; denied actions: manage') + expect(settings.systemPrompt).toContain('Never claim a new attempt failed without a new tool result from this turn') + }) +}) diff --git a/src/__tests__/agentRuntimePermissions.test.ts b/src/__tests__/agentRuntimePermissions.test.ts index b5dc92fd..fe38eee2 100644 --- a/src/__tests__/agentRuntimePermissions.test.ts +++ b/src/__tests__/agentRuntimePermissions.test.ts @@ -15,6 +15,9 @@ const mockStore = { setSessionStatus: jest.fn(), updateToolCallStatus: jest.fn(), findConfirmationRule: jest.fn(), + setMessageStatus: jest.fn(), + clearSessionProgress: jest.fn(), + removeOrphanedStreamingMessages: jest.fn(), setMessageToolCalls: jest.fn((messageId: string, calls: AgentToolCall[], _sessionId: string) => { mockSession.messages = mockSession.messages.map(message => message.id === messageId ? { ...message, toolCalls: calls } : message) }), @@ -39,6 +42,7 @@ jest.mock('@/datasources/agentApi', () => { onAgentLoopThinkingDelta: register('onAgentLoopThinkingDelta'), onAgentLoopToolCall: register('onAgentLoopToolCall'), onAgentLoopToolResult: register('onAgentLoopToolResult'), + onAgentLoopBlocked: register('onAgentLoopBlocked'), onAgentLoopToolRetry: register('onAgentLoopToolRetry'), onAgentLoopWaitingLlm: register('onAgentLoopWaitingLlm'), onAgentLoopWarning: register('onAgentLoopWarning'), @@ -107,9 +111,28 @@ describe('agent permission events', () => { expect(mockStore.updateToolCallStatus).toHaveBeenCalledWith('assistant', 'call', 'denied', 'Permission revoked', undefined, 'chat') }) + it('retains the denial source and pauses without converting it to a session error', () => { + const denial = { kind: 'permission', source: 'connection', connectionId: 'db', action: 'manage', message: 'Manage denied' } + mockHandlers.onAgentLoopToolResult({ ...call(), denied: true, denial, envelope: { summary: denial.message } }) + expect(mockSession.messages[0].toolCalls[0].denial).toEqual(denial) + mockHandlers.onAgentLoopBlocked({ session_id: 'chat', tool_call_id: 'call', denial }) + expect(mockStore.setSessionStatus).toHaveBeenCalledWith('chat', 'blocked') + expect(mockStore.setMessageStatus).toHaveBeenCalledWith('assistant', 'done', 'chat') + expect(mockConfirm).not.toHaveBeenCalled() + expect(mockStore.setSessionStatus).not.toHaveBeenCalledWith('chat', 'error') + }) + it('retains existing confirmation behavior for runtimes without host metadata', () => { mockHandlers.onAgentLoopToolCall({ ...call(), risk_level: 'destructive' }) expect(mockConfirm).not.toHaveBeenCalled() expect(mockSession.messages[0].toolCalls[0].requiresConfirmation).toBe(true) }) + + it('settles other queued calls when a permission block pauses the run', () => { + mockHandlers.onAgentLoopToolCall(call(false)) + mockHandlers.onAgentLoopBlocked({ session_id: 'chat', tool_call_id: 'blocked' }) + expect(mockSession.messages[0].toolCalls[0].status).toBe('interrupted') + jest.advanceTimersByTime(61_000) + expect(mockStore.updateToolCallStatus).not.toHaveBeenCalled() + }) }) diff --git a/src/__tests__/aiPermissions.test.ts b/src/__tests__/aiPermissions.test.ts index af4f6e6a..b5349dea 100644 --- a/src/__tests__/aiPermissions.test.ts +++ b/src/__tests__/aiPermissions.test.ts @@ -1,17 +1,30 @@ /** @jest-environment jsdom */ import { invoke } from '@tauri-apps/api/core' +import { openUrl } from '@tauri-apps/plugin-opener' import { flushPromises, mount } from '@vue/test-utils' -import { defineComponent } from 'vue' -import { AI_ACTIONS, aiPreset, resolveAiPermissions } from '@/common/aiPermissions' +import { defineComponent, reactive } from 'vue' +import { AI_ACTIONS, aiPreset, getAiPolicy, resolveAiPermissions } from '@/common/aiPermissions' import ConnectionPermissionEditor from '@/components/ai-permissions/ConnectionPermissionEditor.vue' import DefaultPermissionEditor from '@/components/ai-permissions/DefaultPermissionEditor.vue' +import ToolPermissionReview from '@/components/ai-permissions/ToolPermissionReview.vue' import SourcePermissionModal from '@/views/data-studio/components/modify-source-modal.vue' jest.mock('@tauri-apps/api/core', () => ({ invoke: jest.fn() })) -jest.mock('@/common', () => jest.requireActual('@/common/aiPermissions')) +jest.mock('@/common', () => ({ ...jest.requireActual('@/common/aiPermissions'), UPGRADE_URL: 'https://example.test/upgrade' })) +const mockEntitlement = reactive({ isLocalUltimate: true }) +jest.mock('@/store', () => ({ useEntitlementStore: () => mockEntitlement, useConnectionStore: () => ({ getConnectionById: (id: string) => ({ id, name: 'Development database' }) }) })) +jest.mock('@tauri-apps/plugin-opener', () => ({ openUrl: jest.fn() })) +jest.mock('@/components/upgrade', () => ({ ProBadge: { template: 'Ultimate' } })) jest.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) jest.mock('@/components/ui/button', () => ({ Button: { template: '' } })) jest.mock('@/components/ui/label', () => ({ Label: { template: '' } })) +jest.mock('@/components/ui/radio-group', () => ({ + RadioGroup: { props: ['modelValue', 'disabled'], emits: ['update:modelValue'], template: '
' }, + RadioGroupItem: { props: ['value'], template: '' }, +})) +jest.mock('@/components/ui/switch', () => ({ + Switch: { props: ['checked', 'disabled'], emits: ['update:checked'], template: '' }, +})) jest.mock('@/components/ui/select', () => ({ Select: { props: ['modelValue'], emits: ['update:modelValue'], template: '' }, SelectTrigger: { template: '' }, @@ -56,13 +69,65 @@ const openEditor = async (id?: string) => { } describe('aI permission chain', () => { + it('persists Full access from the real recovery editor without changing the denying defaults', async () => { + let saved = { permission_defaults: aiPreset('data'), connection_permissions: {} as Record> } + mockEntitlement.isLocalUltimate = true + mockInvoke.mockImplementation(async (command, args) => { + if (command === 'save_ai_permissions') { + const update = args as { connectionId: string, permissions: Record } + saved = { ...saved, connection_permissions: { ...saved.connection_permissions, [update.connectionId]: update.permissions } } + } + return saved + }) + const wrapper = mount(ToolPermissionReview, { props: { open: true, denial: { kind: 'permission', source: 'system', connectionId: 'dev', action: 'manage', message: 'Manage denied' }, toolName: 'ddl', sessionId: 'session' } }) + await flushPromises() + await wrapper.get('input[value="full"]').setValue() + await wrapper.findAll('button').find(button => button.text() === 'dataStudio.modifySource.saveChanges')!.trigger('click') + await flushPromises() + const reloaded = await getAiPolicy() + expect(reloaded.permission_defaults.manage).toBe(false) + expect(reloaded.connection_permissions.dev).toEqual(aiPreset('full')) + expect(resolveAiPermissions(reloaded.permission_defaults, reloaded.connection_permissions.dev).manage).toBe(true) + }) beforeEach(() => { + mockEntitlement.isLocalUltimate = true mockStore.updateSessionSourcePermissions.mockReset() mockStore.detachSourceFromSession.mockReset() mockInvoke.mockReset() mockInvoke.mockResolvedValue(policy) }) + it('gates Community users without loading permissions or blocking ordinary connection saves', async () => { + mockEntitlement.isLocalUltimate = false + const wrapper = mount(ConnectionPermissionEditor, { props: { connectionId: 'dev' } }) + await flushPromises() + expect(mockInvoke).not.toHaveBeenCalled() + expect(wrapper.find('details').exists()).toBe(false) + expect(wrapper.text()).toContain('aiPermissions.ultimateOnly') + expect((wrapper.vm as unknown as { ready: boolean }).ready).toBe(true) + await wrapper.get('button').trigger('click') + expect(openUrl).toHaveBeenCalledWith('https://example.test/upgrade') + mockEntitlement.isLocalUltimate = true + await flushPromises() + expect(mockInvoke).toHaveBeenCalledWith('get_ai_permissions') + expect(wrapper.find('details').exists()).toBe(true) + }) + + it('discards an in-flight permission load when Ultimate access is lost', async () => { + let finishLoad!: (value: typeof policy) => void + mockInvoke.mockImplementationOnce(() => new Promise((resolve) => { + finishLoad = resolve + })) + const wrapper = mount(ConnectionPermissionEditor, { props: { connectionId: 'dev' } }) + mockEntitlement.isLocalUltimate = false + await flushPromises() + finishLoad(policy) + await flushPromises() + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(wrapper.find('details').exists()).toBe(false) + expect((wrapper.vm as unknown as { ready: boolean }).ready).toBe(true) + }) + it('resolves each action with explicit false distinct from inheritance', () => { expect(resolveAiPermissions(aiPreset('full'), { update: false })).toEqual({ read: true, create: true, update: false, delete: true, manage: true }) expect(resolveAiPermissions({}, { read: true })).toEqual(aiPreset('read')) @@ -70,41 +135,85 @@ describe('aI permission chain', () => { expect(AI_ACTIONS.every(action => aiPreset('none')[action] === false)).toBe(true) }) + it('describes the selected access level below the option group', async () => { + const wrapper = mount(DefaultPermissionEditor) + await flushPromises() + ;['none', 'read', 'data', 'full', 'custom'].forEach((value) => { + const option = wrapper.get(`label[for="ai-default-preset-${value}"]`) + expect(option.text()).not.toContain(`${value}Help`) + expect(option.get('input').attributes('aria-describedby')).toBe('ai-default-preset-description') + }) + expect(wrapper.get('#ai-default-preset-description').text()).toMatch(/aiPermissions\.\w+Help/) + await wrapper.get('input[value="full"]').setValue() + expect(wrapper.get('#ai-default-preset-description').text()).toContain('aiPermissions.fullHelp') + }) + it('loads saved decisions and displays effective access from the whole chain', async () => { const wrapper = await openEditor('dev') expect(wrapper.vm.rules).toEqual({ update: true, delete: false }) expect(wrapper.text()).toContain('aiPermissions.effective: aiPermissions.read, aiPermissions.update') - await wrapper.get('button').trigger('click') + expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(5) expect(wrapper.text()).toContain('aiPermissions.fromConnection') - expect(wrapper.text()).toContain('aiPermissions.fromSystem') }) - it('supports inherit, explicit allow and explicit deny independently', async () => { + it('supports explicit allow and deny independently per action', async () => { const wrapper = await openEditor('dev') - await wrapper.get('button').trigger('click') - const actionSelect = () => wrapper.findAll('select')[3] - await actionSelect().setValue('deny') + const updateSwitch = () => wrapper.get('input#ai-dev-update') + await updateSwitch().setValue(false) expect(wrapper.vm.rules).toEqual({ update: false, delete: false }) - await actionSelect().setValue('inherit') - expect(wrapper.vm.rules).toEqual({ delete: false }) - await actionSelect().setValue('allow') + await updateSwitch().setValue(true) expect(wrapper.vm.rules).toEqual({ update: true, delete: false }) }) it('keeps custom decisions when opening the custom preset', async () => { const wrapper = await openEditor('dev') - await wrapper.get('select').setValue('custom') + await wrapper.get('input[value="custom"]').setValue() expect(wrapper.vm.rules).toEqual({ update: true, delete: false }) - expect(wrapper.findAll('select')).toHaveLength(6) + expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(5) }) it('new connections inherit and presets preserve explicit decisions', async () => { const wrapper = await openEditor() expect(wrapper.vm.rules).toEqual({}) - await wrapper.get('select').setValue('none') + await wrapper.get('input[value="none"]').setValue() expect(wrapper.vm.rules).toEqual(aiPreset('none')) - await wrapper.get('select').setValue('inherit') + await wrapper.get('input[value="inherit"]').setValue() + expect(wrapper.vm.rules).toEqual({}) + }) + + it('offers Custom from inherited access without changing stored decisions', async () => { + const wrapper = await openEditor() + await wrapper.get('input[value="custom"]').setValue() + expect(wrapper.vm.rules).toEqual({}) + expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(5) + await wrapper.findAll('input[type="checkbox"]')[1].setValue(true) + expect(wrapper.vm.rules).toEqual({ create: true }) + await wrapper.get('input[value="inherit"]').setValue() expect(wrapper.vm.rules).toEqual({}) + expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(0) + }) + + it('shows custom defaults on load and keeps action editing open when values match a preset', async () => { + mockInvoke.mockResolvedValueOnce({ permission_defaults: { ...aiPreset('read'), create: true }, connection_permissions: {} }) + const wrapper = mount(DefaultPermissionEditor) + await flushPromises() + expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(5) + await wrapper.get('#ai-default-create').setValue(false) + expect(wrapper.get('[data-value]').attributes('data-value')).toBe('custom') + expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(5) + await wrapper.get('button').trigger('click') + await flushPromises() + expect(mockInvoke).toHaveBeenLastCalledWith('save_ai_permissions', { defaults: aiPreset('read') }) + }) + + it('entering Custom does not change default permissions or introduce a separate policy', async () => { + const wrapper = mount(DefaultPermissionEditor) + await flushPromises() + await wrapper.get('input[value="custom"]').setValue() + expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(5) + await wrapper.get('button').trigger('click') + await flushPromises() + expect(mockInvoke).toHaveBeenLastCalledWith('save_ai_permissions', { defaults: aiPreset('read') }) }) it('blocks readiness after load failure and recovers through Retry', async () => { @@ -121,11 +230,11 @@ describe('aI permission chain', () => { it('retries failed default saves without discarding the edited permissions', async () => { const wrapper = mount(DefaultPermissionEditor) await flushPromises() - await wrapper.get('select').setValue('full') + await wrapper.get('input[value="full"]').setValue() mockInvoke.mockRejectedValueOnce(new Error('Temporarily unavailable')) await wrapper.findAll('button').find(button => button.text() === 'aiPermissions.save')!.trigger('click') await flushPromises() - expect(wrapper.get('select').element.value).toBe('full') + expect(wrapper.get('[data-value]').attributes('data-value')).toBe('full') await wrapper.findAll('button').find(button => button.text() === 'aiPermissions.retry')!.trigger('click') await flushPromises() expect(mockInvoke).toHaveBeenLastCalledWith('save_ai_permissions', { defaults: aiPreset('full') }) @@ -162,7 +271,7 @@ describe('aI permission chain', () => { it('saves system defaults without overwriting connection decisions', async () => { const wrapper = mount(DefaultPermissionEditor) await flushPromises() - await wrapper.get('select').setValue('none') + await wrapper.get('input[value="none"]').setValue() await wrapper.findAll('button').find(button => button.text() === 'aiPermissions.save')!.trigger('click') await flushPromises() expect(mockInvoke).toHaveBeenLastCalledWith('save_ai_permissions', { defaults: aiPreset('none') }) diff --git a/src/common/agentPrompt.test.ts b/src/common/agentPrompt.test.ts index d1c60c55..b97a1ac2 100644 --- a/src/common/agentPrompt.test.ts +++ b/src/common/agentPrompt.test.ts @@ -1,4 +1,4 @@ -import { assembleSystemPrompt } from './agentPrompt' +import { assembleSystemPrompt, buildAgentModeInstructions, buildAgentPermissionInstructions } from './agentPrompt' describe('assembleSystemPrompt', () => { it('joins layers in developer, global, connections, sidebar order', () => { @@ -33,3 +33,28 @@ describe('assembleSystemPrompt', () => { expect(assembleSystemPrompt({ base: ' ', userGlobal: '\n', sidebarContext: '' })).toBe('') }) }) + +describe('agent mode contract', () => { + it('refreshes manage access after a connection grant instead of replaying a historical default denial', () => { + const denied = buildAgentPermissionInstructions([{ connectionId: 'example', permissions: { read: true, manage: false } }]).join('\n') + const granted = buildAgentPermissionInstructions([{ connectionId: 'example', permissions: { read: true, manage: true } }]).join('\n') + expect(denied).toContain('denied actions: manage') + expect(granted).toContain('allowed actions: read, manage; denied actions: none') + expect(granted).toContain('Never claim a new attempt failed without a new tool result from this turn') + expect(granted).toContain('submit a fresh tool call') + expect(granted).toContain('not operating-system or database privileges') + }) + it.each([false, true])('keeps reads automatic and all deletes and DDL subject to application approval (auto=%s)', (auto) => { + const prompt = buildAgentModeInstructions(auto).join('\n') + expect(prompt).toContain('read-only queries and metadata reads execute automatically in both modes') + expect(prompt).toContain('CREATE, ALTER, DROP, DELETE with or without WHERE, and TRUNCATE') + expect(prompt).toContain('saved confirmation rules') + expect(prompt).toContain('Approval does not grant a denied permission') + expect(prompt).toContain('Do not substitute a conversational approval question') + }) + + it('requires approval for normal writes in Ask and executes them automatically in Auto', () => { + expect(buildAgentModeInstructions(false).join('\n')).toContain('inserts and updates require approval in ASK') + expect(buildAgentModeInstructions(true).join('\n')).toContain('inserts and updates execute automatically in AUTO') + }) +}) diff --git a/src/common/agentPrompt.ts b/src/common/agentPrompt.ts index bc9bbfde..bfbc7f59 100644 --- a/src/common/agentPrompt.ts +++ b/src/common/agentPrompt.ts @@ -1,3 +1,5 @@ +import type { AiPermissions } from './aiPermissions' + export type ConnectionPrompt = { alias: string prompt: string @@ -10,6 +12,32 @@ export type SystemPromptLayers = { sidebarContext?: string } +export const buildAgentModeInstructions = (auto: boolean): string[] => [ + `## Mode: ${auto ? 'AUTO' : 'ASK'}`, + 'Permitted read-only queries and metadata reads execute automatically in both modes.', + auto + ? 'Permitted inserts and updates execute automatically in AUTO mode.' + : 'Permitted inserts and updates require approval in ASK mode.', + 'All permitted deletes and structure/administration changes require approval in both modes, including CREATE, ALTER, DROP, DELETE with or without WHERE, and TRUNCATE.', + 'Submit the tool call so the application can apply current permissions and saved confirmation rules and display any required approval card. Do not substitute a conversational approval question for that card.', + 'Approval does not grant a denied permission. If an action is blocked or rejected, do not retry it or use another tool to bypass the decision. Wait for an explicit user request to retry.', +] + +export const buildAgentPermissionInstructions = (sources: Array<{ connectionId: string, permissions?: AiPermissions }>): string[] => [ + '## Current SQLKit AI permissions', + 'This snapshot is refreshed for this request and resolves connection decisions, system AI defaults, and conversation restrictions. System AI defaults are application settings, not operating-system or database privileges. An explicit connection decision takes priority over a system default.', + ...sources.map(({ connectionId, permissions }) => { + if (!permissions) + return `- ${connectionId}: permissions unavailable; the application must check access.` + const actions = Object.entries(permissions) + const allowed = actions.filter(([, value]) => value).map(([action]) => action).join(', ') || 'none' + const denied = actions.filter(([, value]) => !value).map(([action]) => action).join(', ') || 'none' + return `- ${connectionId}: allowed actions: ${allowed}; denied actions: ${denied}.` + }), + 'Permission failures in earlier tool results are historical. They do not prove current denial after settings change. Never claim a new attempt failed without a new tool result from this turn.', + 'When the user explicitly asks to retry and current permissions allow the action, submit a fresh tool call and let the application recheck access and request any required approval. Do not repeat an earlier denial as the result of a new attempt.', +] + const isNonEmpty = (value: string | undefined | null): value is string => Boolean(value?.trim()) export const assembleSystemPrompt = (layers: SystemPromptLayers): string => { diff --git a/src/common/aiPermissions.ts b/src/common/aiPermissions.ts index 98cdd95e..1aea6353 100644 --- a/src/common/aiPermissions.ts +++ b/src/common/aiPermissions.ts @@ -3,6 +3,14 @@ import { invoke } from '@tauri-apps/api/core' export const AI_ACTIONS = ['read', 'create', 'update', 'delete', 'manage'] as const export type AiAction = typeof AI_ACTIONS[number] export type AiPermissions = Partial> +export type AccessDenial = { + kind: 'permission' | 'rule' | 'user' | 'unavailable' + source: 'system' | 'connection' | 'conversation' | 'rule' | 'unknown' + message: string + connectionId?: string + sourceId?: string + action?: AiAction +} export type AiPolicy = { permission_defaults: AiPermissions, connection_permissions: Record } export const getAiPolicy = () => invoke('get_ai_permissions') export const saveAiPermissions = (connectionId: string, permissions: AiPermissions) => diff --git a/src/components/DefaultAvatar.vue b/src/components/DefaultAvatar.vue index 0d4cfcac..d56d55c6 100644 --- a/src/components/DefaultAvatar.vue +++ b/src/components/DefaultAvatar.vue @@ -44,7 +44,7 @@ const gradientId = `dfg-${Math.random().toString(36).slice(2, 8)}-${bucket.value
@@ -643,7 +715,7 @@ function toolVerb(toolName: string, tc: AgentToolCall): string { } .activity-status-icon.denied { - color: hsl(var(--muted-foreground)); + color: hsl(var(--destructive-text)); } .result-preview { @@ -657,7 +729,8 @@ function toolVerb(toolName: string, tc: AgentToolCall): string { min-width: 0; } -.result-preview-error { +.result-preview-error, +.result-preview-denied { color: hsl(var(--destructive-text)); } @@ -689,10 +762,31 @@ function toolVerb(toolName: string, tc: AgentToolCall): string { border-radius: 4px; } -.result-body-error { +.result-body-error, +.result-body-denied { color: hsl(var(--destructive-text)); } +.tool-outcome-notice { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 4px 0 12px 20px; + padding: 10px 12px; + border: 1px solid hsl(var(--destructive-border)); + border-radius: 6px; + background: hsl(var(--destructive-muted)); + color: hsl(var(--destructive-text)); + font-size: 13px; + overflow-wrap: anywhere; +} + +.tool-outcome-notice--rejected { + border-color: hsl(var(--warning-border)); + background: hsl(var(--warning-muted)); + color: hsl(var(--warning)); +} + .activity-chevron { width: 11px; height: 11px; diff --git a/src/components/ai-permissions/AccessLevelPicker.vue b/src/components/ai-permissions/AccessLevelPicker.vue new file mode 100644 index 00000000..07df99d6 --- /dev/null +++ b/src/components/ai-permissions/AccessLevelPicker.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/src/components/ai-permissions/ConnectionPermissionEditor.vue b/src/components/ai-permissions/ConnectionPermissionEditor.vue index e3800590..464f99d3 100644 --- a/src/components/ai-permissions/ConnectionPermissionEditor.vue +++ b/src/components/ai-permissions/ConnectionPermissionEditor.vue @@ -1,29 +1,40 @@