Apply recent changes
This commit is contained in:
@@ -35,7 +35,7 @@ tui-textarea = "0.6"
|
|||||||
# HTTP client and JSON handling
|
# HTTP client and JSON handling
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = { version = "1.0" }
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use crossterm::{
|
|||||||
};
|
};
|
||||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main(flavor = "multi_thread")]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let matches = Command::new("owlen-code")
|
let matches = Command::new("owlen-code")
|
||||||
.about("OWLEN Code Mode - TUI optimized for programming assistance")
|
.about("OWLEN Code Mode - TUI optimized for programming assistance")
|
||||||
@@ -32,6 +32,8 @@ async fn main() -> Result<()> {
|
|||||||
.get_matches();
|
.get_matches();
|
||||||
|
|
||||||
let mut config = config::try_load_config().unwrap_or_default();
|
let mut config = config::try_load_config().unwrap_or_default();
|
||||||
|
// Disable encryption for code mode.
|
||||||
|
config.privacy.encrypt_local_data = false;
|
||||||
|
|
||||||
if let Some(model) = matches.get_one::<String>("model") {
|
if let Some(model) = matches.get_one::<String>("model") {
|
||||||
config.general.default_model = Some(model.clone());
|
config.general.default_model = Some(model.clone());
|
||||||
@@ -56,7 +58,15 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let storage = Arc::new(StorageManager::new().await?);
|
let storage = Arc::new(StorageManager::new().await?);
|
||||||
// Code client - code execution tools enabled
|
// Code client - code execution tools enabled
|
||||||
let controller = SessionController::new(provider, config.clone(), storage.clone(), true)?;
|
use owlen_core::ui::NoOpUiController;
|
||||||
|
let controller = SessionController::new(
|
||||||
|
provider,
|
||||||
|
config.clone(),
|
||||||
|
storage.clone(),
|
||||||
|
Arc::new(NoOpUiController),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let (mut app, mut session_rx) = CodeApp::new(controller).await?;
|
let (mut app, mut session_rx) = CodeApp::new(controller).await?;
|
||||||
app.inner_mut().initialize_models().await?;
|
app.inner_mut().initialize_models().await?;
|
||||||
|
|
||||||
@@ -76,7 +86,7 @@ async fn main() -> Result<()> {
|
|||||||
cancellation_token.cancel();
|
cancellation_token.cancel();
|
||||||
event_handle.await?;
|
event_handle.await?;
|
||||||
|
|
||||||
config::save_config(app.inner().config())?;
|
config::save_config(&app.inner().config())?;
|
||||||
|
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(
|
execute!(
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! OWLEN CLI - Chat TUI client
|
//! OWLEN CLI - Chat TUI client
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Arg, Command};
|
|
||||||
use owlen_core::{session::SessionController, storage::StorageManager};
|
use owlen_core::{session::SessionController, storage::StorageManager};
|
||||||
use owlen_ollama::OllamaProvider;
|
use owlen_ollama::OllamaProvider;
|
||||||
|
use owlen_tui::tui_controller::{TuiController, TuiRequest};
|
||||||
use owlen_tui::{config, ui, AppState, ChatApp, Event, EventHandler, SessionEvent};
|
use owlen_tui::{config, ui, AppState, ChatApp, Event, EventHandler, SessionEvent};
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -15,49 +15,41 @@ use crossterm::{
|
|||||||
execute,
|
execute,
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
};
|
};
|
||||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
use ratatui::{prelude::CrosstermBackend, Terminal};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main(flavor = "multi_thread")]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let matches = Command::new("owlen")
|
// (imports completed above)
|
||||||
.about("OWLEN - A chat-focused TUI client for Ollama")
|
|
||||||
.version(env!("CARGO_PKG_VERSION"))
|
|
||||||
.arg(
|
|
||||||
Arg::new("model")
|
|
||||||
.short('m')
|
|
||||||
.long("model")
|
|
||||||
.value_name("MODEL")
|
|
||||||
.help("Preferred model to use for this session"),
|
|
||||||
)
|
|
||||||
.get_matches();
|
|
||||||
|
|
||||||
let mut config = config::try_load_config().unwrap_or_default();
|
// (main logic starts below)
|
||||||
|
// Set auto-consent for TUI mode to prevent blocking stdin reads
|
||||||
|
std::env::set_var("OWLEN_AUTO_CONSENT", "1");
|
||||||
|
|
||||||
if let Some(model) = matches.get_one::<String>("model") {
|
let (tui_tx, _tui_rx) = mpsc::unbounded_channel::<TuiRequest>();
|
||||||
config.general.default_model = Some(model.clone());
|
let tui_controller = Arc::new(TuiController::new(tui_tx));
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare provider from configuration
|
|
||||||
let provider_name = config.general.default_provider.clone();
|
|
||||||
let provider_cfg = config::ensure_provider_config(&mut config, &provider_name).clone();
|
|
||||||
|
|
||||||
|
// Load configuration (or fall back to defaults) for the session controller.
|
||||||
|
let mut cfg = config::try_load_config().unwrap_or_default();
|
||||||
|
// Disable encryption for CLI to avoid password prompts in this environment.
|
||||||
|
cfg.privacy.encrypt_local_data = false;
|
||||||
|
// Determine provider configuration
|
||||||
|
let provider_name = cfg.general.default_provider.clone();
|
||||||
|
let provider_cfg = config::ensure_provider_config(&mut cfg, &provider_name).clone();
|
||||||
let provider_type = provider_cfg.provider_type.to_ascii_lowercase();
|
let provider_type = provider_cfg.provider_type.to_ascii_lowercase();
|
||||||
if provider_type != "ollama" && provider_type != "ollama-cloud" {
|
if provider_type != "ollama" && provider_type != "ollama-cloud" {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"Unsupported provider type '{}' configured for provider '{}'",
|
"Unsupported provider type '{}' configured for provider '{}'",
|
||||||
provider_cfg.provider_type,
|
provider_cfg.provider_type,
|
||||||
provider_name
|
provider_name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let provider = Arc::new(OllamaProvider::from_config(
|
let provider = Arc::new(OllamaProvider::from_config(
|
||||||
&provider_cfg,
|
&provider_cfg,
|
||||||
Some(&config.general),
|
Some(&cfg.general),
|
||||||
)?);
|
)?);
|
||||||
|
|
||||||
let storage = Arc::new(StorageManager::new().await?);
|
let storage = Arc::new(StorageManager::new().await?);
|
||||||
// Chat client - code execution tools disabled (only available in code client)
|
let controller =
|
||||||
let controller = SessionController::new(provider, config.clone(), storage.clone(), false)?;
|
SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?;
|
||||||
let (mut app, mut session_rx) = ChatApp::new(controller).await?;
|
let (mut app, mut session_rx) = ChatApp::new(controller).await?;
|
||||||
app.initialize_models().await?;
|
app.initialize_models().await?;
|
||||||
|
|
||||||
@@ -86,7 +78,7 @@ async fn main() -> Result<()> {
|
|||||||
event_handle.await?;
|
event_handle.await?;
|
||||||
|
|
||||||
// Persist configuration updates (e.g., selected model)
|
// Persist configuration updates (e.g., selected model)
|
||||||
config::save_config(app.config())?;
|
config::save_config(&app.config())?;
|
||||||
|
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(
|
execute!(
|
||||||
|
|||||||
@@ -9,20 +9,20 @@ homepage.workspace = true
|
|||||||
description = "Core traits and types for OWLEN LLM client"
|
description = "Core traits and types for OWLEN LLM client"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.75"
|
anyhow = { workspace = true }
|
||||||
log = "0.4.20"
|
log = "0.4.20"
|
||||||
serde = { version = "1.0.188", features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0.105"
|
serde_json = { workspace = true }
|
||||||
thiserror = "1.0.48"
|
thiserror = { workspace = true }
|
||||||
tokio = { version = "1.32.0", features = ["full"] }
|
tokio = { workspace = true }
|
||||||
unicode-segmentation = "1.11"
|
unicode-segmentation = "1.11"
|
||||||
unicode-width = "0.1"
|
unicode-width = "0.1"
|
||||||
uuid = { version = "1.4.1", features = ["v4", "serde"] }
|
uuid = { workspace = true }
|
||||||
textwrap = "0.16.0"
|
textwrap = { workspace = true }
|
||||||
futures = "0.3.28"
|
futures = { workspace = true }
|
||||||
async-trait = "0.1.73"
|
async-trait = { workspace = true }
|
||||||
toml = "0.8.0"
|
toml = { workspace = true }
|
||||||
shellexpand = "3.1.0"
|
shellexpand = { workspace = true }
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
ratatui = { workspace = true }
|
ratatui = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
@@ -33,6 +33,7 @@ aes-gcm = { workspace = true }
|
|||||||
ring = { workspace = true }
|
ring = { workspace = true }
|
||||||
keyring = { workspace = true }
|
keyring = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
crossterm = { workspace = true }
|
||||||
urlencoding = { workspace = true }
|
urlencoding = { workspace = true }
|
||||||
rpassword = { workspace = true }
|
rpassword = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
|
|||||||
@@ -289,7 +289,12 @@ impl SecuritySettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn default_allowed_tools() -> Vec<String> {
|
fn default_allowed_tools() -> Vec<String> {
|
||||||
vec!["web_search".to_string(), "code_exec".to_string()]
|
vec![
|
||||||
|
"web_search".to_string(),
|
||||||
|
"code_exec".to_string(),
|
||||||
|
"file_write".to_string(),
|
||||||
|
"file_delete".to_string(),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,23 @@ pub struct ConsentRequest {
|
|||||||
pub tool_name: String,
|
pub tool_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scope of consent grant
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ConsentScope {
|
||||||
|
/// Grant only for this single operation
|
||||||
|
Once,
|
||||||
|
/// Grant for the duration of the current session
|
||||||
|
Session,
|
||||||
|
/// Grant permanently (persisted across sessions)
|
||||||
|
Permanent,
|
||||||
|
/// Explicitly denied
|
||||||
|
Denied,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
pub struct ConsentRecord {
|
pub struct ConsentRecord {
|
||||||
pub tool_name: String,
|
pub tool_name: String,
|
||||||
pub granted: bool,
|
pub scope: ConsentScope,
|
||||||
pub timestamp: DateTime<Utc>,
|
pub timestamp: DateTime<Utc>,
|
||||||
pub data_types: Vec<String>,
|
pub data_types: Vec<String>,
|
||||||
pub external_endpoints: Vec<String>,
|
pub external_endpoints: Vec<String>,
|
||||||
@@ -24,7 +37,17 @@ pub struct ConsentRecord {
|
|||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default)]
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
pub struct ConsentManager {
|
pub struct ConsentManager {
|
||||||
records: HashMap<String, ConsentRecord>,
|
/// Permanent consent records (persisted to vault)
|
||||||
|
permanent_records: HashMap<String, ConsentRecord>,
|
||||||
|
/// Session-scoped consent (cleared on manager drop or explicit clear)
|
||||||
|
#[serde(skip)]
|
||||||
|
session_records: HashMap<String, ConsentRecord>,
|
||||||
|
/// Once-scoped consent (used once then cleared)
|
||||||
|
#[serde(skip)]
|
||||||
|
once_records: HashMap<String, ConsentRecord>,
|
||||||
|
/// Pending consent requests (to prevent duplicate prompts)
|
||||||
|
#[serde(skip)]
|
||||||
|
pending_requests: HashMap<String, ()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConsentManager {
|
impl ConsentManager {
|
||||||
@@ -36,19 +59,24 @@ impl ConsentManager {
|
|||||||
pub fn from_vault(vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Self {
|
pub fn from_vault(vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Self {
|
||||||
let guard = vault.lock().expect("Vault mutex poisoned");
|
let guard = vault.lock().expect("Vault mutex poisoned");
|
||||||
if let Some(consent_data) = guard.settings().get("consent_records") {
|
if let Some(consent_data) = guard.settings().get("consent_records") {
|
||||||
if let Ok(records) =
|
if let Ok(permanent_records) =
|
||||||
serde_json::from_value::<HashMap<String, ConsentRecord>>(consent_data.clone())
|
serde_json::from_value::<HashMap<String, ConsentRecord>>(consent_data.clone())
|
||||||
{
|
{
|
||||||
return Self { records };
|
return Self {
|
||||||
|
permanent_records,
|
||||||
|
session_records: HashMap::new(),
|
||||||
|
once_records: HashMap::new(),
|
||||||
|
pending_requests: HashMap::new(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist consent records to vault storage
|
/// Persist permanent consent records to vault storage
|
||||||
pub fn persist_to_vault(&self, vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Result<()> {
|
pub fn persist_to_vault(&self, vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Result<()> {
|
||||||
let mut guard = vault.lock().expect("Vault mutex poisoned");
|
let mut guard = vault.lock().expect("Vault mutex poisoned");
|
||||||
let consent_json = serde_json::to_value(&self.records)?;
|
let consent_json = serde_json::to_value(&self.permanent_records)?;
|
||||||
guard
|
guard
|
||||||
.settings_mut()
|
.settings_mut()
|
||||||
.insert("consent_records".to_string(), consent_json);
|
.insert("consent_records".to_string(), consent_json);
|
||||||
@@ -61,24 +89,60 @@ impl ConsentManager {
|
|||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
data_types: Vec<String>,
|
data_types: Vec<String>,
|
||||||
endpoints: Vec<String>,
|
endpoints: Vec<String>,
|
||||||
) -> Result<bool> {
|
) -> Result<ConsentScope> {
|
||||||
if let Some(existing) = self.records.get(tool_name) {
|
// Check if already granted permanently
|
||||||
return Ok(existing.granted);
|
if let Some(existing) = self.permanent_records.get(tool_name) {
|
||||||
|
if existing.scope == ConsentScope::Permanent {
|
||||||
|
return Ok(ConsentScope::Permanent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let consent = self.show_consent_dialog(tool_name, &data_types, &endpoints)?;
|
// Check if granted for session
|
||||||
|
if let Some(existing) = self.session_records.get(tool_name) {
|
||||||
|
if existing.scope == ConsentScope::Session {
|
||||||
|
return Ok(ConsentScope::Session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if request is already pending (prevent duplicate prompts)
|
||||||
|
if self.pending_requests.contains_key(tool_name) {
|
||||||
|
// Wait for the other prompt to complete by returning denied temporarily
|
||||||
|
// The caller should retry after a short delay
|
||||||
|
return Ok(ConsentScope::Denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as pending
|
||||||
|
self.pending_requests.insert(tool_name.to_string(), ());
|
||||||
|
|
||||||
|
// Show consent dialog and get scope
|
||||||
|
let scope = self.show_consent_dialog(tool_name, &data_types, &endpoints)?;
|
||||||
|
|
||||||
|
// Remove from pending
|
||||||
|
self.pending_requests.remove(tool_name);
|
||||||
|
|
||||||
|
// Create record based on scope
|
||||||
let record = ConsentRecord {
|
let record = ConsentRecord {
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
granted: consent,
|
scope: scope.clone(),
|
||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
data_types,
|
data_types,
|
||||||
external_endpoints: endpoints,
|
external_endpoints: endpoints,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.records.insert(tool_name.to_string(), record);
|
// Store in appropriate location
|
||||||
// Note: Caller should persist to vault after this call
|
match scope {
|
||||||
Ok(consent)
|
ConsentScope::Permanent => {
|
||||||
|
self.permanent_records.insert(tool_name.to_string(), record);
|
||||||
|
}
|
||||||
|
ConsentScope::Session => {
|
||||||
|
self.session_records.insert(tool_name.to_string(), record);
|
||||||
|
}
|
||||||
|
ConsentScope::Once | ConsentScope::Denied => {
|
||||||
|
// Don't store, just return the decision
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Grant consent programmatically (for TUI or automated flows)
|
/// Grant consent programmatically (for TUI or automated flows)
|
||||||
@@ -87,15 +151,38 @@ impl ConsentManager {
|
|||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
data_types: Vec<String>,
|
data_types: Vec<String>,
|
||||||
endpoints: Vec<String>,
|
endpoints: Vec<String>,
|
||||||
|
) {
|
||||||
|
self.grant_consent_with_scope(tool_name, data_types, endpoints, ConsentScope::Permanent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grant consent with specific scope
|
||||||
|
pub fn grant_consent_with_scope(
|
||||||
|
&mut self,
|
||||||
|
tool_name: &str,
|
||||||
|
data_types: Vec<String>,
|
||||||
|
endpoints: Vec<String>,
|
||||||
|
scope: ConsentScope,
|
||||||
) {
|
) {
|
||||||
let record = ConsentRecord {
|
let record = ConsentRecord {
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
granted: true,
|
scope: scope.clone(),
|
||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
data_types,
|
data_types,
|
||||||
external_endpoints: endpoints,
|
external_endpoints: endpoints,
|
||||||
};
|
};
|
||||||
self.records.insert(tool_name.to_string(), record);
|
|
||||||
|
match scope {
|
||||||
|
ConsentScope::Permanent => {
|
||||||
|
self.permanent_records.insert(tool_name.to_string(), record);
|
||||||
|
}
|
||||||
|
ConsentScope::Session => {
|
||||||
|
self.session_records.insert(tool_name.to_string(), record);
|
||||||
|
}
|
||||||
|
ConsentScope::Once => {
|
||||||
|
self.once_records.insert(tool_name.to_string(), record);
|
||||||
|
}
|
||||||
|
ConsentScope::Denied => {} // Denied is not stored
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if consent is needed (returns None if already granted, Some(info) if needed)
|
/// Check if consent is needed (returns None if already granted, Some(info) if needed)
|
||||||
@@ -110,21 +197,44 @@ impl ConsentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_consent(&self, tool_name: &str) -> bool {
|
pub fn has_consent(&self, tool_name: &str) -> bool {
|
||||||
self.records
|
// Check permanent first, then session, then once
|
||||||
|
self.permanent_records
|
||||||
.get(tool_name)
|
.get(tool_name)
|
||||||
.map(|record| record.granted)
|
.map(|r| r.scope == ConsentScope::Permanent)
|
||||||
|
.or_else(|| {
|
||||||
|
self.session_records
|
||||||
|
.get(tool_name)
|
||||||
|
.map(|r| r.scope == ConsentScope::Session)
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
self.once_records
|
||||||
|
.get(tool_name)
|
||||||
|
.map(|r| r.scope == ConsentScope::Once)
|
||||||
|
})
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Consume "once" consent for a tool (clears it after first use)
|
||||||
|
pub fn consume_once_consent(&mut self, tool_name: &str) {
|
||||||
|
self.once_records.remove(tool_name);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn revoke_consent(&mut self, tool_name: &str) {
|
pub fn revoke_consent(&mut self, tool_name: &str) {
|
||||||
if let Some(record) = self.records.get_mut(tool_name) {
|
self.permanent_records.remove(tool_name);
|
||||||
record.granted = false;
|
self.session_records.remove(tool_name);
|
||||||
record.timestamp = Utc::now();
|
self.once_records.remove(tool_name);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_all_consent(&mut self) {
|
pub fn clear_all_consent(&mut self) {
|
||||||
self.records.clear();
|
self.permanent_records.clear();
|
||||||
|
self.session_records.clear();
|
||||||
|
self.once_records.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear only session-scoped consent (useful when starting new session)
|
||||||
|
pub fn clear_session_consent(&mut self) {
|
||||||
|
self.session_records.clear();
|
||||||
|
self.once_records.clear(); // Also clear once consent on session clear
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if consent is needed for a tool (non-blocking)
|
/// Check if consent is needed for a tool (non-blocking)
|
||||||
@@ -146,27 +256,40 @@ impl ConsentManager {
|
|||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
data_types: &[String],
|
data_types: &[String],
|
||||||
endpoints: &[String],
|
endpoints: &[String],
|
||||||
) -> Result<bool> {
|
) -> Result<ConsentScope> {
|
||||||
// TEMPORARY: Auto-grant consent when not in a proper terminal (TUI mode)
|
// TEMPORARY: Auto-grant session consent when not in a proper terminal (TUI mode)
|
||||||
// TODO: Integrate consent UI into the TUI event loop
|
// TODO: Integrate consent UI into the TUI event loop
|
||||||
use std::io::IsTerminal;
|
use std::io::IsTerminal;
|
||||||
if !io::stdin().is_terminal() || std::env::var("OWLEN_AUTO_CONSENT").is_ok() {
|
if !io::stdin().is_terminal() || std::env::var("OWLEN_AUTO_CONSENT").is_ok() {
|
||||||
eprintln!("Auto-granting consent for {} (TUI mode)", tool_name);
|
eprintln!("Auto-granting session consent for {} (TUI mode)", tool_name);
|
||||||
return Ok(true);
|
return Ok(ConsentScope::Session);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("=== PRIVACY CONSENT REQUIRED ===");
|
println!("\n╔══════════════════════════════════════════════════╗");
|
||||||
println!("Tool: {}", tool_name);
|
println!("║ 🔒 PRIVACY CONSENT REQUIRED 🔒 ║");
|
||||||
println!("Data to be sent: {}", data_types.join(", "));
|
println!("╚══════════════════════════════════════════════════╝");
|
||||||
println!("External endpoints: {}", endpoints.join(", "));
|
println!();
|
||||||
println!("Do you consent to this data transmission? (y/N)");
|
println!("Tool: {}", tool_name);
|
||||||
|
println!("Data: {}", data_types.join(", "));
|
||||||
print!("> ");
|
println!("Endpoints: {}", endpoints.join(", "));
|
||||||
|
println!();
|
||||||
|
println!("Choose consent scope:");
|
||||||
|
println!(" [1] Allow once - Grant only for this operation");
|
||||||
|
println!(" [2] Allow session - Grant for current session");
|
||||||
|
println!(" [3] Allow always - Grant permanently");
|
||||||
|
println!(" [4] Deny - Reject this operation");
|
||||||
|
println!();
|
||||||
|
print!("Enter choice (1-4) [default: 4]: ");
|
||||||
io::stdout().flush()?;
|
io::stdout().flush()?;
|
||||||
|
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
io::stdin().read_line(&mut input)?;
|
io::stdin().read_line(&mut input)?;
|
||||||
|
|
||||||
Ok(matches!(input.trim().to_lowercase().as_str(), "y" | "yes"))
|
match input.trim() {
|
||||||
|
"1" => Ok(ConsentScope::Once),
|
||||||
|
"2" => Ok(ConsentScope::Session),
|
||||||
|
"3" => Ok(ConsentScope::Permanent),
|
||||||
|
_ => Ok(ConsentScope::Denied),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,4 +79,7 @@ pub enum Error {
|
|||||||
|
|
||||||
#[error("Not implemented: {0}")]
|
#[error("Not implemented: {0}")]
|
||||||
NotImplemented(String),
|
NotImplemented(String),
|
||||||
|
|
||||||
|
#[error("Permission denied: {0}")]
|
||||||
|
PermissionDenied(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,19 @@ pub struct RemoteMcpClient;
|
|||||||
|
|
||||||
impl RemoteMcpClient {
|
impl RemoteMcpClient {
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
Ok(Self)
|
// Attempt to spawn the MCP server binary located at ./target/debug/owlen-mcp-server
|
||||||
|
// The server runs over STDIO and will be managed by the client instance.
|
||||||
|
// For now we just verify that the binary exists; the actual process handling
|
||||||
|
// is performed lazily in the async methods.
|
||||||
|
let path = "./target/debug/owlen-mcp-server";
|
||||||
|
if std::path::Path::new(path).exists() {
|
||||||
|
Ok(Self)
|
||||||
|
} else {
|
||||||
|
Err(Error::NotImplemented(format!(
|
||||||
|
"Remote MCP server binary not found at {}",
|
||||||
|
path
|
||||||
|
)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
87
crates/owlen-core/src/mcp/factory.rs
Normal file
87
crates/owlen-core/src/mcp/factory.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/// MCP Client Factory
|
||||||
|
///
|
||||||
|
/// Provides a unified interface for creating MCP clients based on configuration.
|
||||||
|
/// Supports switching between local (in-process) and remote (STDIO) execution modes.
|
||||||
|
use super::client::McpClient;
|
||||||
|
use super::{remote_client::RemoteMcpClient, LocalMcpClient};
|
||||||
|
use crate::config::{Config, McpMode};
|
||||||
|
use crate::tools::registry::ToolRegistry;
|
||||||
|
use crate::validation::SchemaValidator;
|
||||||
|
use crate::Result;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Factory for creating MCP clients based on configuration
|
||||||
|
pub struct McpClientFactory {
|
||||||
|
config: Arc<Config>,
|
||||||
|
registry: Arc<ToolRegistry>,
|
||||||
|
validator: Arc<SchemaValidator>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl McpClientFactory {
|
||||||
|
pub fn new(
|
||||||
|
config: Arc<Config>,
|
||||||
|
registry: Arc<ToolRegistry>,
|
||||||
|
validator: Arc<SchemaValidator>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
registry,
|
||||||
|
validator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an MCP client based on the current configuration
|
||||||
|
pub fn create(&self) -> Result<Box<dyn McpClient>> {
|
||||||
|
match self.config.mcp.mode {
|
||||||
|
McpMode::Legacy => {
|
||||||
|
// Use local in-process client
|
||||||
|
Ok(Box::new(LocalMcpClient::new(
|
||||||
|
self.registry.clone(),
|
||||||
|
self.validator.clone(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
McpMode::Enabled => {
|
||||||
|
// Attempt to use remote client, fall back to local if unavailable
|
||||||
|
match RemoteMcpClient::new() {
|
||||||
|
Ok(client) => Ok(Box::new(client)),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Warning: Failed to start remote MCP client: {}. Falling back to local mode.", e);
|
||||||
|
Ok(Box::new(LocalMcpClient::new(
|
||||||
|
self.registry.clone(),
|
||||||
|
self.validator.clone(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if remote MCP mode is available
|
||||||
|
pub fn is_remote_available() -> bool {
|
||||||
|
RemoteMcpClient::new().is_ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_factory_creates_local_client_in_legacy_mode() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.mcp.mode = McpMode::Legacy;
|
||||||
|
|
||||||
|
let ui = Arc::new(crate::ui::NoOpUiController);
|
||||||
|
let registry = Arc::new(ToolRegistry::new(
|
||||||
|
Arc::new(tokio::sync::Mutex::new(config.clone())),
|
||||||
|
ui,
|
||||||
|
));
|
||||||
|
let validator = Arc::new(SchemaValidator::new());
|
||||||
|
|
||||||
|
let factory = McpClientFactory::new(Arc::new(config), registry, validator);
|
||||||
|
|
||||||
|
// Should create without error
|
||||||
|
let result = factory.create();
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod factory;
|
||||||
|
pub mod permission;
|
||||||
|
pub mod protocol;
|
||||||
|
pub mod remote_client;
|
||||||
|
|
||||||
/// Descriptor for a tool exposed over MCP
|
/// Descriptor for a tool exposed over MCP
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
217
crates/owlen-core/src/mcp/permission.rs
Normal file
217
crates/owlen-core/src/mcp/permission.rs
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/// Permission and Safety Layer for MCP
|
||||||
|
///
|
||||||
|
/// This module provides runtime enforcement of security policies for tool execution.
|
||||||
|
/// It wraps MCP clients to filter/whitelist tool calls, log invocations, and prompt for consent.
|
||||||
|
use super::client::McpClient;
|
||||||
|
use super::{McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::{Error, Result};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Callback for requesting user consent for dangerous operations
|
||||||
|
pub type ConsentCallback = Arc<dyn Fn(&str, &McpToolCall) -> bool + Send + Sync>;
|
||||||
|
|
||||||
|
/// Callback for logging tool invocations
|
||||||
|
pub type LogCallback = Arc<dyn Fn(&str, &McpToolCall, &Result<McpToolResponse>) + Send + Sync>;
|
||||||
|
|
||||||
|
/// Permission-enforcing wrapper around an MCP client
|
||||||
|
pub struct PermissionLayer {
|
||||||
|
inner: Box<dyn McpClient>,
|
||||||
|
config: Arc<Config>,
|
||||||
|
consent_callback: Option<ConsentCallback>,
|
||||||
|
log_callback: Option<LogCallback>,
|
||||||
|
allowed_tools: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PermissionLayer {
|
||||||
|
/// Create a new permission layer wrapping the given client
|
||||||
|
pub fn new(inner: Box<dyn McpClient>, config: Arc<Config>) -> Self {
|
||||||
|
let allowed_tools = config.security.allowed_tools.iter().cloned().collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
config,
|
||||||
|
consent_callback: None,
|
||||||
|
log_callback: None,
|
||||||
|
allowed_tools,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a callback for requesting user consent
|
||||||
|
pub fn with_consent_callback(mut self, callback: ConsentCallback) -> Self {
|
||||||
|
self.consent_callback = Some(callback);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a callback for logging tool invocations
|
||||||
|
pub fn with_log_callback(mut self, callback: LogCallback) -> Self {
|
||||||
|
self.log_callback = Some(callback);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a tool requires dangerous filesystem operations
|
||||||
|
fn requires_dangerous_filesystem(&self, tool_name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
tool_name,
|
||||||
|
"resources/write" | "resources/delete" | "file_write" | "file_delete"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a tool is allowed by security policy
|
||||||
|
fn is_tool_allowed(&self, tool_descriptor: &McpToolDescriptor) -> bool {
|
||||||
|
// Check if tool requires filesystem access
|
||||||
|
for fs_perm in &tool_descriptor.requires_filesystem {
|
||||||
|
if !self.allowed_tools.contains(fs_perm) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if tool requires network access
|
||||||
|
if tool_descriptor.requires_network && !self.allowed_tools.contains("web_search") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request user consent for a tool call
|
||||||
|
fn request_consent(&self, tool_name: &str, call: &McpToolCall) -> bool {
|
||||||
|
if let Some(ref callback) = self.consent_callback {
|
||||||
|
callback(tool_name, call)
|
||||||
|
} else {
|
||||||
|
// If no callback is set, deny dangerous operations by default
|
||||||
|
!self.requires_dangerous_filesystem(tool_name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log a tool invocation
|
||||||
|
fn log_invocation(
|
||||||
|
&self,
|
||||||
|
tool_name: &str,
|
||||||
|
call: &McpToolCall,
|
||||||
|
result: &Result<McpToolResponse>,
|
||||||
|
) {
|
||||||
|
if let Some(ref callback) = self.log_callback {
|
||||||
|
callback(tool_name, call, result);
|
||||||
|
} else {
|
||||||
|
// Default logging to stderr
|
||||||
|
match result {
|
||||||
|
Ok(resp) => {
|
||||||
|
eprintln!(
|
||||||
|
"[MCP] Tool '{}' executed successfully ({}ms)",
|
||||||
|
tool_name, resp.duration_ms
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[MCP] Tool '{}' failed: {}", tool_name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl McpClient for PermissionLayer {
|
||||||
|
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||||
|
let tools = self.inner.list_tools().await?;
|
||||||
|
// Filter tools based on security policy
|
||||||
|
Ok(tools
|
||||||
|
.into_iter()
|
||||||
|
.filter(|tool| self.is_tool_allowed(tool))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
|
||||||
|
// Check if tool requires consent
|
||||||
|
if self.requires_dangerous_filesystem(&call.name)
|
||||||
|
&& self.config.privacy.require_consent_per_session
|
||||||
|
&& !self.request_consent(&call.name, &call)
|
||||||
|
{
|
||||||
|
let result = Err(Error::PermissionDenied(format!(
|
||||||
|
"User denied consent for tool '{}'",
|
||||||
|
call.name
|
||||||
|
)));
|
||||||
|
self.log_invocation(&call.name, &call, &result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the tool call
|
||||||
|
let result = self.inner.call_tool(call.clone()).await;
|
||||||
|
|
||||||
|
// Log the invocation
|
||||||
|
self.log_invocation(&call.name, &call, &result);
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::mcp::LocalMcpClient;
|
||||||
|
use crate::tools::registry::ToolRegistry;
|
||||||
|
use crate::validation::SchemaValidator;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_permission_layer_filters_dangerous_tools() {
|
||||||
|
let config = Arc::new(Config::default());
|
||||||
|
let ui = Arc::new(crate::ui::NoOpUiController);
|
||||||
|
let registry = Arc::new(ToolRegistry::new(
|
||||||
|
Arc::new(tokio::sync::Mutex::new((*config).clone())),
|
||||||
|
ui,
|
||||||
|
));
|
||||||
|
let validator = Arc::new(SchemaValidator::new());
|
||||||
|
let client = Box::new(LocalMcpClient::new(registry, validator));
|
||||||
|
|
||||||
|
let mut config_mut = (*config).clone();
|
||||||
|
// Disallow file operations
|
||||||
|
config_mut.security.allowed_tools = vec!["web_search".to_string()];
|
||||||
|
|
||||||
|
let permission_layer = PermissionLayer::new(client, Arc::new(config_mut));
|
||||||
|
|
||||||
|
let tools = permission_layer.list_tools().await.unwrap();
|
||||||
|
|
||||||
|
// Should not include file_write or file_delete tools
|
||||||
|
assert!(!tools.iter().any(|t| t.name.contains("write")));
|
||||||
|
assert!(!tools.iter().any(|t| t.name.contains("delete")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_consent_callback_is_invoked() {
|
||||||
|
let config = Arc::new(Config::default());
|
||||||
|
let ui = Arc::new(crate::ui::NoOpUiController);
|
||||||
|
let registry = Arc::new(ToolRegistry::new(
|
||||||
|
Arc::new(tokio::sync::Mutex::new((*config).clone())),
|
||||||
|
ui,
|
||||||
|
));
|
||||||
|
let validator = Arc::new(SchemaValidator::new());
|
||||||
|
let client = Box::new(LocalMcpClient::new(registry, validator));
|
||||||
|
|
||||||
|
let consent_called = Arc::new(AtomicBool::new(false));
|
||||||
|
let consent_called_clone = consent_called.clone();
|
||||||
|
|
||||||
|
let consent_callback: ConsentCallback = Arc::new(move |_tool, _call| {
|
||||||
|
consent_called_clone.store(true, Ordering::SeqCst);
|
||||||
|
false // Deny
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut config_mut = (*config).clone();
|
||||||
|
config_mut.privacy.require_consent_per_session = true;
|
||||||
|
|
||||||
|
let permission_layer = PermissionLayer::new(client, Arc::new(config_mut))
|
||||||
|
.with_consent_callback(consent_callback);
|
||||||
|
|
||||||
|
let call = McpToolCall {
|
||||||
|
name: "resources/write".to_string(),
|
||||||
|
arguments: serde_json::json!({"path": "test.txt", "content": "hello"}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = permission_layer.call_tool(call).await;
|
||||||
|
|
||||||
|
assert!(consent_called.load(Ordering::SeqCst));
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
369
crates/owlen-core/src/mcp/protocol.rs
Normal file
369
crates/owlen-core/src/mcp/protocol.rs
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
/// MCP Protocol Definitions
|
||||||
|
///
|
||||||
|
/// This module defines the JSON-RPC protocol contracts for the Model Context Protocol (MCP).
|
||||||
|
/// It includes request/response schemas, error codes, and versioning semantics.
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// MCP Protocol version - uses semantic versioning
|
||||||
|
pub const PROTOCOL_VERSION: &str = "1.0.0";
|
||||||
|
|
||||||
|
/// JSON-RPC version constant
|
||||||
|
pub const JSONRPC_VERSION: &str = "2.0";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Codes and Handling
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Standard JSON-RPC error codes following the spec
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ErrorCode(pub i64);
|
||||||
|
|
||||||
|
impl ErrorCode {
|
||||||
|
// Standard JSON-RPC 2.0 errors
|
||||||
|
pub const PARSE_ERROR: Self = Self(-32700);
|
||||||
|
pub const INVALID_REQUEST: Self = Self(-32600);
|
||||||
|
pub const METHOD_NOT_FOUND: Self = Self(-32601);
|
||||||
|
pub const INVALID_PARAMS: Self = Self(-32602);
|
||||||
|
pub const INTERNAL_ERROR: Self = Self(-32603);
|
||||||
|
|
||||||
|
// MCP-specific errors (range -32000 to -32099)
|
||||||
|
pub const TOOL_NOT_FOUND: Self = Self(-32000);
|
||||||
|
pub const TOOL_EXECUTION_FAILED: Self = Self(-32001);
|
||||||
|
pub const PERMISSION_DENIED: Self = Self(-32002);
|
||||||
|
pub const RESOURCE_NOT_FOUND: Self = Self(-32003);
|
||||||
|
pub const TIMEOUT: Self = Self(-32004);
|
||||||
|
pub const VALIDATION_ERROR: Self = Self(-32005);
|
||||||
|
pub const PATH_TRAVERSAL: Self = Self(-32006);
|
||||||
|
pub const RATE_LIMIT_EXCEEDED: Self = Self(-32007);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structured error response
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RpcError {
|
||||||
|
pub code: i64,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub data: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RpcError {
|
||||||
|
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code: code.0,
|
||||||
|
message: message.into(),
|
||||||
|
data: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_data(mut self, data: Value) -> Self {
|
||||||
|
self.data = Some(data);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_error(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorCode::PARSE_ERROR, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invalid_request(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorCode::INVALID_REQUEST, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn method_not_found(method: &str) -> Self {
|
||||||
|
Self::new(
|
||||||
|
ErrorCode::METHOD_NOT_FOUND,
|
||||||
|
format!("Method not found: {}", method),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invalid_params(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorCode::INVALID_PARAMS, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn internal_error(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorCode::INTERNAL_ERROR, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tool_not_found(tool_name: &str) -> Self {
|
||||||
|
Self::new(
|
||||||
|
ErrorCode::TOOL_NOT_FOUND,
|
||||||
|
format!("Tool not found: {}", tool_name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn permission_denied(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorCode::PERMISSION_DENIED, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path_traversal() -> Self {
|
||||||
|
Self::new(ErrorCode::PATH_TRAVERSAL, "Path traversal attempt detected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Request/Response Structures
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// JSON-RPC request structure
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RpcRequest {
|
||||||
|
pub jsonrpc: String,
|
||||||
|
pub id: RequestId,
|
||||||
|
pub method: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub params: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RpcRequest {
|
||||||
|
pub fn new(id: RequestId, method: impl Into<String>, params: Option<Value>) -> Self {
|
||||||
|
Self {
|
||||||
|
jsonrpc: JSONRPC_VERSION.to_string(),
|
||||||
|
id,
|
||||||
|
method: method.into(),
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON-RPC response structure (success)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RpcResponse {
|
||||||
|
pub jsonrpc: String,
|
||||||
|
pub id: RequestId,
|
||||||
|
pub result: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RpcResponse {
|
||||||
|
pub fn new(id: RequestId, result: Value) -> Self {
|
||||||
|
Self {
|
||||||
|
jsonrpc: JSONRPC_VERSION.to_string(),
|
||||||
|
id,
|
||||||
|
result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON-RPC error response
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RpcErrorResponse {
|
||||||
|
pub jsonrpc: String,
|
||||||
|
pub id: RequestId,
|
||||||
|
pub error: RpcError,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RpcErrorResponse {
|
||||||
|
pub fn new(id: RequestId, error: RpcError) -> Self {
|
||||||
|
Self {
|
||||||
|
jsonrpc: JSONRPC_VERSION.to_string(),
|
||||||
|
id,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request ID can be string, number, or null
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum RequestId {
|
||||||
|
Number(u64),
|
||||||
|
String(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u64> for RequestId {
|
||||||
|
fn from(n: u64) -> Self {
|
||||||
|
Self::Number(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for RequestId {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Self::String(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MCP Method Names
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Standard MCP methods
|
||||||
|
pub mod methods {
|
||||||
|
pub const INITIALIZE: &str = "initialize";
|
||||||
|
pub const TOOLS_LIST: &str = "tools/list";
|
||||||
|
pub const TOOLS_CALL: &str = "tools/call";
|
||||||
|
pub const RESOURCES_LIST: &str = "resources/list";
|
||||||
|
pub const RESOURCES_GET: &str = "resources/get";
|
||||||
|
pub const RESOURCES_WRITE: &str = "resources/write";
|
||||||
|
pub const RESOURCES_DELETE: &str = "resources/delete";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Initialization Protocol
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Initialize request parameters
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct InitializeParams {
|
||||||
|
pub protocol_version: String,
|
||||||
|
pub client_info: ClientInfo,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub capabilities: Option<ClientCapabilities>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for InitializeParams {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
protocol_version: PROTOCOL_VERSION.to_string(),
|
||||||
|
client_info: ClientInfo {
|
||||||
|
name: "owlen".to_string(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
},
|
||||||
|
capabilities: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client information
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ClientInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client capabilities
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ClientCapabilities {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub supports_streaming: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub supports_cancellation: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize response
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct InitializeResult {
|
||||||
|
pub protocol_version: String,
|
||||||
|
pub server_info: ServerInfo,
|
||||||
|
pub capabilities: ServerCapabilities,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server information
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ServerInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server capabilities
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ServerCapabilities {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub supports_tools: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub supports_resources: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub supports_streaming: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tool Call Protocol
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Parameters for tools/list
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ToolsListParams {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub filter: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters for tools/call
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ToolsCallParams {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub arguments: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of tools/call
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ToolsCallResult {
|
||||||
|
pub success: bool,
|
||||||
|
pub output: Value,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Resource Protocol
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Parameters for resources/list
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ResourcesListParams {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters for resources/get
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ResourcesGetParams {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters for resources/write
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ResourcesWriteParams {
|
||||||
|
pub path: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters for resources/delete
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ResourcesDeleteParams {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Versioning and Compatibility
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Check if a protocol version is compatible
|
||||||
|
pub fn is_compatible(client_version: &str, server_version: &str) -> bool {
|
||||||
|
// For now, simple exact match on major version
|
||||||
|
let client_major = client_version.split('.').next().unwrap_or("0");
|
||||||
|
let server_major = server_version.split('.').next().unwrap_or("0");
|
||||||
|
client_major == server_major
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_codes() {
|
||||||
|
let err = RpcError::tool_not_found("test_tool");
|
||||||
|
assert_eq!(err.code, ErrorCode::TOOL_NOT_FOUND.0);
|
||||||
|
assert!(err.message.contains("test_tool"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_version_compatibility() {
|
||||||
|
assert!(is_compatible("1.0.0", "1.0.0"));
|
||||||
|
assert!(is_compatible("1.0.0", "1.1.0"));
|
||||||
|
assert!(is_compatible("1.2.5", "1.0.0"));
|
||||||
|
assert!(!is_compatible("1.0.0", "2.0.0"));
|
||||||
|
assert!(!is_compatible("2.0.0", "1.0.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_request_serialization() {
|
||||||
|
let req = RpcRequest::new(
|
||||||
|
RequestId::Number(1),
|
||||||
|
"tools/call",
|
||||||
|
Some(serde_json::json!({"name": "test"})),
|
||||||
|
);
|
||||||
|
let json = serde_json::to_string(&req).unwrap();
|
||||||
|
assert!(json.contains("\"jsonrpc\":\"2.0\""));
|
||||||
|
assert!(json.contains("\"method\":\"tools/call\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
120
crates/owlen-core/src/mcp/remote_client.rs
Normal file
120
crates/owlen-core/src/mcp/remote_client.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
use super::protocol::{RequestId, RpcErrorResponse, RpcRequest, RpcResponse};
|
||||||
|
use super::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||||
|
use crate::{Error, Result};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::process::{Child, Command};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
/// Client that talks to the external `owlen-mcp-server` over STDIO.
|
||||||
|
pub struct RemoteMcpClient {
|
||||||
|
// Child process handling the server (kept alive for the duration of the client).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
child: Arc<Mutex<Child>>, // guarded for mutable access across calls
|
||||||
|
// Writer to server stdin.
|
||||||
|
stdin: Arc<Mutex<tokio::process::ChildStdin>>, // async write
|
||||||
|
// Reader for server stdout.
|
||||||
|
stdout: Arc<Mutex<BufReader<tokio::process::ChildStdout>>>,
|
||||||
|
// Incrementing request identifier.
|
||||||
|
next_id: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteMcpClient {
|
||||||
|
/// Spawn the MCP server binary and prepare communication channels.
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
// Locate the binary – it is built by Cargo into target/debug.
|
||||||
|
// The test binary runs inside the crate directory, so we check a couple of relative locations.
|
||||||
|
// Attempt to locate the server binary; if unavailable we will fall back to launching via `cargo run`.
|
||||||
|
let _ = ();
|
||||||
|
// Resolve absolute path based on workspace root to avoid cwd dependence.
|
||||||
|
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../..")
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(Error::Io)?;
|
||||||
|
let binary_path = workspace_root.join("target/debug/owlen-mcp-server");
|
||||||
|
if !binary_path.exists() {
|
||||||
|
return Err(Error::NotImplemented(format!(
|
||||||
|
"owlen-mcp-server binary not found at {}",
|
||||||
|
binary_path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// Launch the already‑built server binary directly.
|
||||||
|
let mut child = Command::new(&binary_path)
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::inherit())
|
||||||
|
.spawn()
|
||||||
|
.map_err(Error::Io)?;
|
||||||
|
|
||||||
|
let stdin = child.stdin.take().ok_or_else(|| {
|
||||||
|
Error::Io(std::io::Error::other(
|
||||||
|
"Failed to capture stdin of MCP server",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let stdout = child.stdout.take().ok_or_else(|| {
|
||||||
|
Error::Io(std::io::Error::other(
|
||||||
|
"Failed to capture stdout of MCP server",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
child: Arc::new(Mutex::new(child)),
|
||||||
|
stdin: Arc::new(Mutex::new(stdin)),
|
||||||
|
stdout: Arc::new(Mutex::new(BufReader::new(stdout))),
|
||||||
|
next_id: AtomicU64::new(1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_rpc(&self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
|
||||||
|
let id = RequestId::Number(self.next_id.fetch_add(1, Ordering::Relaxed));
|
||||||
|
let request = RpcRequest::new(id.clone(), method, Some(params));
|
||||||
|
let req_str = serde_json::to_string(&request)? + "\n";
|
||||||
|
{
|
||||||
|
let mut stdin = self.stdin.lock().await;
|
||||||
|
stdin.write_all(req_str.as_bytes()).await?;
|
||||||
|
stdin.flush().await?;
|
||||||
|
}
|
||||||
|
// Read a single line response
|
||||||
|
let mut line = String::new();
|
||||||
|
{
|
||||||
|
let mut stdout = self.stdout.lock().await;
|
||||||
|
stdout.read_line(&mut line).await?;
|
||||||
|
}
|
||||||
|
// Try to parse successful response first
|
||||||
|
if let Ok(resp) = serde_json::from_str::<RpcResponse>(&line) {
|
||||||
|
if resp.id == id {
|
||||||
|
return Ok(resp.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to error response
|
||||||
|
let err_resp: RpcErrorResponse =
|
||||||
|
serde_json::from_str(&line).map_err(Error::Serialization)?;
|
||||||
|
Err(Error::Network(format!(
|
||||||
|
"MCP server error {}: {}",
|
||||||
|
err_resp.error.code, err_resp.error.message
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl McpClient for RemoteMcpClient {
|
||||||
|
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||||
|
// The file server does not expose tool descriptors; fall back to NotImplemented.
|
||||||
|
Err(Error::NotImplemented(
|
||||||
|
"Remote MCP client does not support list_tools".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
|
||||||
|
let result = self.send_rpc(&call.name, call.arguments.clone()).await?;
|
||||||
|
// The remote server returns only the tool result; we fabricate metadata.
|
||||||
|
Ok(McpToolResponse {
|
||||||
|
name: call.name,
|
||||||
|
success: true,
|
||||||
|
output: result,
|
||||||
|
metadata: std::collections::HashMap::new(),
|
||||||
|
duration_ms: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -109,3 +109,89 @@ impl Tool for ResourcesGetTool {
|
|||||||
Ok(ToolResult::success(serde_json::to_value(content)?))
|
Ok(ToolResult::success(serde_json::to_value(content)?))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Write tool – writes (or overwrites) a file under the project root.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
pub struct ResourcesWriteTool;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WriteArgs {
|
||||||
|
path: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for ResourcesWriteTool {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"resources/write"
|
||||||
|
}
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Writes (or overwrites) a file. Requires explicit consent."
|
||||||
|
}
|
||||||
|
fn schema(&self) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": { "type": "string", "description": "Target file path (relative to project root)" },
|
||||||
|
"content": { "type": "string", "description": "File content to write" }
|
||||||
|
},
|
||||||
|
"required": ["path", "content"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn requires_filesystem(&self) -> Vec<String> {
|
||||||
|
vec!["file_write".to_string()]
|
||||||
|
}
|
||||||
|
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
|
||||||
|
let args: WriteArgs = serde_json::from_value(args)?;
|
||||||
|
let root = env::current_dir()?;
|
||||||
|
let full_path = sanitize_path(&args.path, &root)?;
|
||||||
|
// Ensure the parent directory exists
|
||||||
|
if let Some(parent) = full_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::write(full_path, args.content)?;
|
||||||
|
Ok(ToolResult::success(json!(null)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete tool – deletes a file under the project root.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
pub struct ResourcesDeleteTool;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DeleteArgs {
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for ResourcesDeleteTool {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"resources/delete"
|
||||||
|
}
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Deletes a file. Requires explicit consent."
|
||||||
|
}
|
||||||
|
fn schema(&self) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": { "path": { "type": "string", "description": "File path to delete" } },
|
||||||
|
"required": ["path"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn requires_filesystem(&self) -> Vec<String> {
|
||||||
|
vec!["file_delete".to_string()]
|
||||||
|
}
|
||||||
|
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
|
||||||
|
let args: DeleteArgs = serde_json::from_value(args)?;
|
||||||
|
let root = env::current_dir()?;
|
||||||
|
let full_path = sanitize_path(&args.path, &root)?;
|
||||||
|
if full_path.is_file() {
|
||||||
|
fs::remove_file(full_path)?;
|
||||||
|
Ok(ToolResult::success(json!(null)))
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!("Path does not refer to a file"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{json, Value};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use async_trait::async_trait;
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
pub mod code_exec;
|
pub mod code_exec;
|
||||||
pub mod fs_tools;
|
pub mod fs_tools;
|
||||||
@@ -10,6 +11,13 @@ pub mod registry;
|
|||||||
pub mod web_search;
|
pub mod web_search;
|
||||||
pub mod web_search_detailed;
|
pub mod web_search_detailed;
|
||||||
|
|
||||||
|
// Re‑export tool structs for convenient crate‑level access
|
||||||
|
pub use code_exec::CodeExecTool;
|
||||||
|
pub use fs_tools::{ResourcesDeleteTool, ResourcesGetTool, ResourcesListTool, ResourcesWriteTool};
|
||||||
|
pub use registry::ToolRegistry;
|
||||||
|
pub use web_search::WebSearchTool;
|
||||||
|
pub use web_search_detailed::WebSearchDetailedTool;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Tool: Send + Sync {
|
pub trait Tool: Send + Sync {
|
||||||
fn name(&self) -> &'static str;
|
fn name(&self) -> &'static str;
|
||||||
@@ -25,30 +33,42 @@ pub trait Tool: Send + Sync {
|
|||||||
async fn execute(&self, args: Value) -> Result<ToolResult>;
|
async fn execute(&self, args: Value) -> Result<ToolResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ToolResult {
|
pub struct ToolResult {
|
||||||
pub success: bool,
|
pub success: bool,
|
||||||
|
pub cancelled: bool,
|
||||||
pub output: Value,
|
pub output: Value,
|
||||||
pub duration: std::time::Duration,
|
|
||||||
pub metadata: HashMap<String, String>,
|
pub metadata: HashMap<String, String>,
|
||||||
|
pub duration: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolResult {
|
impl ToolResult {
|
||||||
pub fn success(output: Value) -> Self {
|
pub fn success(output: Value) -> Self {
|
||||||
Self {
|
Self {
|
||||||
success: true,
|
success: true,
|
||||||
|
cancelled: false,
|
||||||
output,
|
output,
|
||||||
duration: std::time::Duration::from_millis(0),
|
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
|
duration: Duration::from_millis(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn error(message: &str) -> Self {
|
pub fn error(message: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
success: false,
|
success: false,
|
||||||
output: serde_json::json!({ "error": message }),
|
cancelled: false,
|
||||||
duration: std::time::Duration::from_millis(0),
|
output: json!({ "error": message }),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
|
duration: Duration::from_millis(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancelled(message: String) -> Self {
|
||||||
|
Self {
|
||||||
|
success: false,
|
||||||
|
cancelled: true,
|
||||||
|
output: json!({ "message": message }),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
duration: Duration::from_millis(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,22 @@ use std::sync::Arc;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use super::Tool;
|
use super::{Tool, ToolResult};
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::ui::UiController;
|
||||||
|
|
||||||
pub struct ToolRegistry {
|
pub struct ToolRegistry {
|
||||||
tools: HashMap<String, Arc<dyn Tool>>,
|
tools: HashMap<String, Arc<dyn Tool>>,
|
||||||
}
|
config: Arc<tokio::sync::Mutex<Config>>,
|
||||||
|
ui: Arc<dyn UiController>,
|
||||||
impl Default for ToolRegistry {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolRegistry {
|
impl ToolRegistry {
|
||||||
pub fn new() -> Self {
|
pub fn new(config: Arc<tokio::sync::Mutex<Config>>, ui: Arc<dyn UiController>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
tools: HashMap::new(),
|
tools: HashMap::new(),
|
||||||
|
config,
|
||||||
|
ui,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,10 +40,39 @@ impl ToolRegistry {
|
|||||||
self.tools.values().cloned().collect()
|
self.tools.values().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn execute(&self, name: &str, args: Value) -> Result<super::ToolResult> {
|
pub async fn execute(&self, name: &str, args: Value) -> Result<ToolResult> {
|
||||||
let tool = self
|
let tool = self
|
||||||
.get(name)
|
.get(name)
|
||||||
.with_context(|| format!("Tool not registered: {}", name))?;
|
.with_context(|| format!("Tool not registered: {}", name))?;
|
||||||
|
|
||||||
|
let mut config = self.config.lock().await;
|
||||||
|
|
||||||
|
let is_enabled = match name {
|
||||||
|
"web_search" => config.tools.web_search.enabled,
|
||||||
|
"code_exec" => config.tools.code_exec.enabled,
|
||||||
|
_ => true, // All other tools are considered enabled by default
|
||||||
|
};
|
||||||
|
|
||||||
|
if !is_enabled {
|
||||||
|
let prompt = format!(
|
||||||
|
"Tool '{}' is disabled. Would you like to enable it for this session?",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
if self.ui.confirm(&prompt).await {
|
||||||
|
// Enable the tool in the in-memory config for the current session
|
||||||
|
match name {
|
||||||
|
"web_search" => config.tools.web_search.enabled = true,
|
||||||
|
"code_exec" => config.tools.code_exec.enabled = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Ok(ToolResult::cancelled(format!(
|
||||||
|
"Tool '{}' execution was cancelled by the user.",
|
||||||
|
name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tool.execute(args).await
|
tool.execute(args).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -351,6 +351,42 @@ pub fn find_prev_word_boundary(line: &str, col: usize) -> Option<usize> {
|
|||||||
Some(pos)
|
Some(pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use crate::theme::Theme;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use std::io::stdout;
|
||||||
|
|
||||||
|
pub fn show_mouse_cursor() {
|
||||||
|
let mut stdout = stdout();
|
||||||
|
crossterm::execute!(stdout, crossterm::cursor::Show).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hide_mouse_cursor() {
|
||||||
|
let mut stdout = stdout();
|
||||||
|
crossterm::execute!(stdout, crossterm::cursor::Hide).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_theme_to_string(s: &str, _theme: &Theme) -> String {
|
||||||
|
// This is a placeholder. In a real implementation, you'd parse the string
|
||||||
|
// and apply colors based on syntax or other rules.
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trait for abstracting UI interactions like confirmations.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UiController: Send + Sync {
|
||||||
|
async fn confirm(&self, prompt: &str) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A no-op UI controller for non-interactive contexts.
|
||||||
|
pub struct NoOpUiController;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UiController for NoOpUiController {
|
||||||
|
async fn confirm(&self, _prompt: &str) -> bool {
|
||||||
|
false // Always decline in non-interactive mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
99
crates/owlen-core/tests/consent_scope.rs
Normal file
99
crates/owlen-core/tests/consent_scope.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
use owlen_core::consent::{ConsentManager, ConsentScope};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_consent_scopes() {
|
||||||
|
let mut manager = ConsentManager::new();
|
||||||
|
|
||||||
|
// Test session consent
|
||||||
|
manager.grant_consent_with_scope(
|
||||||
|
"test_tool",
|
||||||
|
vec!["data".to_string()],
|
||||||
|
vec!["https://example.com".to_string()],
|
||||||
|
ConsentScope::Session,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(manager.has_consent("test_tool"));
|
||||||
|
|
||||||
|
// Clear session consent and verify it's gone
|
||||||
|
manager.clear_session_consent();
|
||||||
|
assert!(!manager.has_consent("test_tool"));
|
||||||
|
|
||||||
|
// Test permanent consent survives session clear
|
||||||
|
manager.grant_consent_with_scope(
|
||||||
|
"test_tool_permanent",
|
||||||
|
vec!["data".to_string()],
|
||||||
|
vec!["https://example.com".to_string()],
|
||||||
|
ConsentScope::Permanent,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(manager.has_consent("test_tool_permanent"));
|
||||||
|
manager.clear_session_consent();
|
||||||
|
assert!(manager.has_consent("test_tool_permanent"));
|
||||||
|
|
||||||
|
// Verify revoke works for permanent consent
|
||||||
|
manager.revoke_consent("test_tool_permanent");
|
||||||
|
assert!(!manager.has_consent("test_tool_permanent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pending_requests_prevents_duplicates() {
|
||||||
|
let mut manager = ConsentManager::new();
|
||||||
|
|
||||||
|
// Simulate concurrent consent requests by checking pending state
|
||||||
|
// In real usage, multiple threads would call request_consent simultaneously
|
||||||
|
|
||||||
|
// First, verify a tool has no consent
|
||||||
|
assert!(!manager.has_consent("web_search"));
|
||||||
|
|
||||||
|
// The pending_requests map is private, but we can test the behavior
|
||||||
|
// by checking that consent checks work correctly
|
||||||
|
assert!(manager.check_consent_needed("web_search").is_some());
|
||||||
|
|
||||||
|
// Grant session consent
|
||||||
|
manager.grant_consent_with_scope(
|
||||||
|
"web_search",
|
||||||
|
vec!["search queries".to_string()],
|
||||||
|
vec!["https://api.search.com".to_string()],
|
||||||
|
ConsentScope::Session,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now it should have consent
|
||||||
|
assert!(manager.has_consent("web_search"));
|
||||||
|
assert!(manager.check_consent_needed("web_search").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_consent_record_separation() {
|
||||||
|
let mut manager = ConsentManager::new();
|
||||||
|
|
||||||
|
// Add permanent consent
|
||||||
|
manager.grant_consent_with_scope(
|
||||||
|
"perm_tool",
|
||||||
|
vec!["data".to_string()],
|
||||||
|
vec!["https://perm.com".to_string()],
|
||||||
|
ConsentScope::Permanent,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add session consent
|
||||||
|
manager.grant_consent_with_scope(
|
||||||
|
"session_tool",
|
||||||
|
vec!["data".to_string()],
|
||||||
|
vec!["https://session.com".to_string()],
|
||||||
|
ConsentScope::Session,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both should have consent
|
||||||
|
assert!(manager.has_consent("perm_tool"));
|
||||||
|
assert!(manager.has_consent("session_tool"));
|
||||||
|
|
||||||
|
// Clear session consent
|
||||||
|
manager.clear_session_consent();
|
||||||
|
|
||||||
|
// Only permanent should remain
|
||||||
|
assert!(manager.has_consent("perm_tool"));
|
||||||
|
assert!(!manager.has_consent("session_tool"));
|
||||||
|
|
||||||
|
// Clear all
|
||||||
|
manager.clear_all_consent();
|
||||||
|
assert!(!manager.has_consent("perm_tool"));
|
||||||
|
}
|
||||||
53
crates/owlen-core/tests/file_server.rs
Normal file
53
crates/owlen-core/tests/file_server.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use owlen_core::mcp::client::McpClient;
|
||||||
|
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
||||||
|
use owlen_core::mcp::McpToolCall;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remote_file_server_read_and_list() {
|
||||||
|
// Create temporary directory with a file
|
||||||
|
let dir = tempdir().expect("tempdir failed");
|
||||||
|
let file_path = dir.path().join("hello.txt");
|
||||||
|
let mut file = File::create(&file_path).expect("create file");
|
||||||
|
writeln!(file, "world").expect("write file");
|
||||||
|
|
||||||
|
// Change current directory for the test process so the server sees the temp dir as its root
|
||||||
|
std::env::set_current_dir(dir.path()).expect("set cwd");
|
||||||
|
|
||||||
|
// Ensure the MCP server binary is built.
|
||||||
|
// Build the MCP server binary using the workspace manifest.
|
||||||
|
let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../..")
|
||||||
|
.join("Cargo.toml");
|
||||||
|
let build_status = std::process::Command::new("cargo")
|
||||||
|
.args(&["build", "-p", "owlen-mcp-server", "--manifest-path"])
|
||||||
|
.arg(manifest_path)
|
||||||
|
.status()
|
||||||
|
.expect("failed to run cargo build for MCP server");
|
||||||
|
assert!(build_status.success(), "MCP server build failed");
|
||||||
|
|
||||||
|
// Spawn remote client after the cwd is set and binary built
|
||||||
|
let client = RemoteMcpClient::new().expect("remote client init");
|
||||||
|
|
||||||
|
// Read file via MCP
|
||||||
|
let call = McpToolCall {
|
||||||
|
name: "resources/get".to_string(),
|
||||||
|
arguments: serde_json::json!({"path": "hello.txt"}),
|
||||||
|
};
|
||||||
|
let resp = client.call_tool(call).await.expect("call_tool");
|
||||||
|
let content: String = serde_json::from_value(resp.output).expect("parse output");
|
||||||
|
assert!(content.trim().ends_with("world"));
|
||||||
|
|
||||||
|
// List directory via MCP
|
||||||
|
let list_call = McpToolCall {
|
||||||
|
name: "resources/list".to_string(),
|
||||||
|
arguments: serde_json::json!({"path": "."}),
|
||||||
|
};
|
||||||
|
let list_resp = client.call_tool(list_call).await.expect("list_tool");
|
||||||
|
let entries: Vec<String> = serde_json::from_value(list_resp.output).expect("parse list");
|
||||||
|
assert!(entries.contains(&"hello.txt".to_string()));
|
||||||
|
|
||||||
|
// Cleanup handled by tempdir
|
||||||
|
}
|
||||||
68
crates/owlen-core/tests/file_write.rs
Normal file
68
crates/owlen-core/tests/file_write.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
use owlen_core::mcp::client::McpClient;
|
||||||
|
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
||||||
|
use owlen_core::mcp::McpToolCall;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remote_write_and_delete() {
|
||||||
|
// Build the server binary first
|
||||||
|
let status = std::process::Command::new("cargo")
|
||||||
|
.args(&["build", "-p", "owlen-mcp-server"])
|
||||||
|
.status()
|
||||||
|
.expect("failed to build MCP server");
|
||||||
|
assert!(status.success());
|
||||||
|
|
||||||
|
// Use a temp dir as project root
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
std::env::set_current_dir(dir.path()).expect("set cwd");
|
||||||
|
|
||||||
|
let client = RemoteMcpClient::new().expect("client init");
|
||||||
|
|
||||||
|
// Write a file via MCP
|
||||||
|
let write_call = McpToolCall {
|
||||||
|
name: "resources/write".to_string(),
|
||||||
|
arguments: serde_json::json!({ "path": "test.txt", "content": "hello" }),
|
||||||
|
};
|
||||||
|
client.call_tool(write_call).await.expect("write tool");
|
||||||
|
|
||||||
|
// Verify content via local read (fallback check)
|
||||||
|
let content = std::fs::read_to_string(dir.path().join("test.txt")).expect("read back");
|
||||||
|
assert_eq!(content, "hello");
|
||||||
|
|
||||||
|
// Delete the file via MCP
|
||||||
|
let del_call = McpToolCall {
|
||||||
|
name: "resources/delete".to_string(),
|
||||||
|
arguments: serde_json::json!({ "path": "test.txt" }),
|
||||||
|
};
|
||||||
|
client.call_tool(del_call).await.expect("delete tool");
|
||||||
|
assert!(!dir.path().join("test.txt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn write_outside_root_is_rejected() {
|
||||||
|
// Build server (already built in previous test, but ensure it exists)
|
||||||
|
let status = std::process::Command::new("cargo")
|
||||||
|
.args(&["build", "-p", "owlen-mcp-server"])
|
||||||
|
.status()
|
||||||
|
.expect("failed to build MCP server");
|
||||||
|
assert!(status.success());
|
||||||
|
|
||||||
|
// Set cwd to a fresh temp dir
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
std::env::set_current_dir(dir.path()).expect("set cwd");
|
||||||
|
let client = RemoteMcpClient::new().expect("client init");
|
||||||
|
|
||||||
|
// Attempt to write outside the root using "../evil.txt"
|
||||||
|
let call = McpToolCall {
|
||||||
|
name: "resources/write".to_string(),
|
||||||
|
arguments: serde_json::json!({ "path": "../evil.txt", "content": "bad" }),
|
||||||
|
};
|
||||||
|
let err = client.call_tool(call).await.unwrap_err();
|
||||||
|
// The server returns a Network error with path traversal message
|
||||||
|
let err_str = format!("{err}");
|
||||||
|
assert!(
|
||||||
|
err_str.contains("path traversal") || err_str.contains("Path traversal"),
|
||||||
|
"Expected path traversal error, got: {}",
|
||||||
|
err_str
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,3 +9,4 @@ serde = { version = "1.0", features = ["derive"] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
path-clean = "1.0"
|
path-clean = "1.0"
|
||||||
|
owlen-core = { path = "../owlen-core" }
|
||||||
|
|||||||
@@ -1,71 +1,112 @@
|
|||||||
|
use owlen_core::mcp::protocol::{
|
||||||
|
is_compatible, ErrorCode, InitializeParams, InitializeResult, RequestId, RpcError,
|
||||||
|
RpcErrorResponse, RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, PROTOCOL_VERSION,
|
||||||
|
};
|
||||||
use path_clean::PathClean;
|
use path_clean::PathClean;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
|
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct Request {
|
|
||||||
id: u64,
|
|
||||||
method: String,
|
|
||||||
params: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct Response {
|
|
||||||
id: u64,
|
|
||||||
result: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct ErrorResponse {
|
|
||||||
id: u64,
|
|
||||||
error: JsonRpcError,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct JsonRpcError {
|
|
||||||
code: i64,
|
|
||||||
message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct FileArgs {
|
struct FileArgs {
|
||||||
path: String,
|
path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_request(req: Request, root: &Path) -> Result<serde_json::Value, JsonRpcError> {
|
#[derive(Deserialize)]
|
||||||
|
struct WriteArgs {
|
||||||
|
path: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_request(req: &RpcRequest, root: &Path) -> Result<serde_json::Value, RpcError> {
|
||||||
match req.method.as_str() {
|
match req.method.as_str() {
|
||||||
|
"initialize" => {
|
||||||
|
let params = req
|
||||||
|
.params
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| RpcError::invalid_params("Missing params for initialize"))?;
|
||||||
|
|
||||||
|
let init_params: InitializeParams =
|
||||||
|
serde_json::from_value(params.clone()).map_err(|e| {
|
||||||
|
RpcError::invalid_params(format!("Invalid initialize params: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Check protocol version compatibility
|
||||||
|
if !is_compatible(&init_params.protocol_version, PROTOCOL_VERSION) {
|
||||||
|
return Err(RpcError::new(
|
||||||
|
ErrorCode::INVALID_REQUEST,
|
||||||
|
format!(
|
||||||
|
"Incompatible protocol version. Client: {}, Server: {}",
|
||||||
|
init_params.protocol_version, PROTOCOL_VERSION
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build initialization result
|
||||||
|
let result = InitializeResult {
|
||||||
|
protocol_version: PROTOCOL_VERSION.to_string(),
|
||||||
|
server_info: ServerInfo {
|
||||||
|
name: "owlen-mcp-server".to_string(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
},
|
||||||
|
capabilities: ServerCapabilities {
|
||||||
|
supports_tools: Some(false),
|
||||||
|
supports_resources: Some(true), // Supports read, write, delete
|
||||||
|
supports_streaming: Some(false),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(serde_json::to_value(result).map_err(|e| {
|
||||||
|
RpcError::internal_error(format!("Failed to serialize result: {}", e))
|
||||||
|
})?)
|
||||||
|
}
|
||||||
"resources/list" => {
|
"resources/list" => {
|
||||||
let args: FileArgs = serde_json::from_value(req.params).map_err(|e| JsonRpcError {
|
let params = req
|
||||||
code: -32602,
|
.params
|
||||||
message: format!("Invalid params: {}", e),
|
.as_ref()
|
||||||
})?;
|
.ok_or_else(|| RpcError::invalid_params("Missing params"))?;
|
||||||
|
let args: FileArgs = serde_json::from_value(params.clone())
|
||||||
|
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
|
||||||
resources_list(&args.path, root).await
|
resources_list(&args.path, root).await
|
||||||
}
|
}
|
||||||
"resources/get" => {
|
"resources/get" => {
|
||||||
let args: FileArgs = serde_json::from_value(req.params).map_err(|e| JsonRpcError {
|
let params = req
|
||||||
code: -32602,
|
.params
|
||||||
message: format!("Invalid params: {}", e),
|
.as_ref()
|
||||||
})?;
|
.ok_or_else(|| RpcError::invalid_params("Missing params"))?;
|
||||||
|
let args: FileArgs = serde_json::from_value(params.clone())
|
||||||
|
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
|
||||||
resources_get(&args.path, root).await
|
resources_get(&args.path, root).await
|
||||||
}
|
}
|
||||||
_ => Err(JsonRpcError {
|
"resources/write" => {
|
||||||
code: -32601,
|
let params = req
|
||||||
message: "Method not found".to_string(),
|
.params
|
||||||
}),
|
.as_ref()
|
||||||
|
.ok_or_else(|| RpcError::invalid_params("Missing params"))?;
|
||||||
|
let args: WriteArgs = serde_json::from_value(params.clone())
|
||||||
|
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
|
||||||
|
resources_write(&args.path, &args.content, root).await
|
||||||
|
}
|
||||||
|
"resources/delete" => {
|
||||||
|
let params = req
|
||||||
|
.params
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| RpcError::invalid_params("Missing params"))?;
|
||||||
|
let args: FileArgs = serde_json::from_value(params.clone())
|
||||||
|
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
|
||||||
|
resources_delete(&args.path, root).await
|
||||||
|
}
|
||||||
|
_ => Err(RpcError::method_not_found(&req.method)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf, JsonRpcError> {
|
fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf, RpcError> {
|
||||||
let path = Path::new(path);
|
let path = Path::new(path);
|
||||||
let path = if path.is_absolute() {
|
let path = if path.is_absolute() {
|
||||||
path.strip_prefix("/")
|
path.strip_prefix("/")
|
||||||
.map_err(|_| JsonRpcError {
|
.map_err(|_| RpcError::invalid_params("Invalid path"))?
|
||||||
code: -32602,
|
|
||||||
message: "Invalid path".to_string(),
|
|
||||||
})?
|
|
||||||
.to_path_buf()
|
.to_path_buf()
|
||||||
} else {
|
} else {
|
||||||
path.to_path_buf()
|
path.to_path_buf()
|
||||||
@@ -74,28 +115,26 @@ fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf, JsonRpcError> {
|
|||||||
let full_path = root.join(path).clean();
|
let full_path = root.join(path).clean();
|
||||||
|
|
||||||
if !full_path.starts_with(root) {
|
if !full_path.starts_with(root) {
|
||||||
return Err(JsonRpcError {
|
return Err(RpcError::path_traversal());
|
||||||
code: -32602,
|
|
||||||
message: "Path traversal detected".to_string(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(full_path)
|
Ok(full_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resources_list(path: &str, root: &Path) -> Result<serde_json::Value, JsonRpcError> {
|
async fn resources_list(path: &str, root: &Path) -> Result<serde_json::Value, RpcError> {
|
||||||
let full_path = sanitize_path(path, root)?;
|
let full_path = sanitize_path(path, root)?;
|
||||||
|
|
||||||
let entries = fs::read_dir(full_path).map_err(|e| JsonRpcError {
|
let entries = fs::read_dir(full_path).map_err(|e| {
|
||||||
code: -32000,
|
RpcError::new(
|
||||||
message: format!("Failed to read directory: {}", e),
|
ErrorCode::RESOURCE_NOT_FOUND,
|
||||||
|
format!("Failed to read directory: {}", e),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let entry = entry.map_err(|e| JsonRpcError {
|
let entry = entry.map_err(|e| {
|
||||||
code: -32000,
|
RpcError::internal_error(format!("Failed to read directory entry: {}", e))
|
||||||
message: format!("Failed to read directory entry: {}", e),
|
|
||||||
})?;
|
})?;
|
||||||
result.push(entry.file_name().to_string_lossy().to_string());
|
result.push(entry.file_name().to_string_lossy().to_string());
|
||||||
}
|
}
|
||||||
@@ -103,17 +142,50 @@ async fn resources_list(path: &str, root: &Path) -> Result<serde_json::Value, Js
|
|||||||
Ok(serde_json::json!(result))
|
Ok(serde_json::json!(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resources_get(path: &str, root: &Path) -> Result<serde_json::Value, JsonRpcError> {
|
async fn resources_get(path: &str, root: &Path) -> Result<serde_json::Value, RpcError> {
|
||||||
let full_path = sanitize_path(path, root)?;
|
let full_path = sanitize_path(path, root)?;
|
||||||
|
|
||||||
let content = fs::read_to_string(full_path).map_err(|e| JsonRpcError {
|
let content = fs::read_to_string(full_path).map_err(|e| {
|
||||||
code: -32000,
|
RpcError::new(
|
||||||
message: format!("Failed to read file: {}", e),
|
ErrorCode::RESOURCE_NOT_FOUND,
|
||||||
|
format!("Failed to read file: {}", e),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(serde_json::json!(content))
|
Ok(serde_json::json!(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resources_write(
|
||||||
|
path: &str,
|
||||||
|
content: &str,
|
||||||
|
root: &Path,
|
||||||
|
) -> Result<serde_json::Value, RpcError> {
|
||||||
|
let full_path = sanitize_path(path, root)?;
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if let Some(parent) = full_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| {
|
||||||
|
RpcError::internal_error(format!("Failed to create parent directories: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
std::fs::write(full_path, content)
|
||||||
|
.map_err(|e| RpcError::internal_error(format!("Failed to write file: {}", e)))?;
|
||||||
|
Ok(serde_json::json!(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resources_delete(path: &str, root: &Path) -> Result<serde_json::Value, RpcError> {
|
||||||
|
let full_path = sanitize_path(path, root)?;
|
||||||
|
if full_path.is_file() {
|
||||||
|
std::fs::remove_file(full_path)
|
||||||
|
.map_err(|e| RpcError::internal_error(format!("Failed to delete file: {}", e)))?;
|
||||||
|
Ok(serde_json::json!(null))
|
||||||
|
} else {
|
||||||
|
Err(RpcError::new(
|
||||||
|
ErrorCode::RESOURCE_NOT_FOUND,
|
||||||
|
"Path does not refer to a file",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let root = env::current_dir()?;
|
let root = env::current_dir()?;
|
||||||
@@ -128,43 +200,37 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let req: Request = match serde_json::from_str(&line) {
|
let req: RpcRequest = match serde_json::from_str(&line) {
|
||||||
Ok(req) => req,
|
Ok(req) => req,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let err_resp = ErrorResponse {
|
let err_resp = RpcErrorResponse::new(
|
||||||
id: 0,
|
RequestId::Number(0),
|
||||||
error: JsonRpcError {
|
RpcError::parse_error(format!("Parse error: {}", e)),
|
||||||
code: -32700,
|
);
|
||||||
message: format!("Parse error: {}", e),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let resp_str = serde_json::to_string(&err_resp)?;
|
let resp_str = serde_json::to_string(&err_resp)?;
|
||||||
stdout.write_all(resp_str.as_bytes()).await?;
|
stdout.write_all(resp_str.as_bytes()).await?;
|
||||||
stdout.write_all(b"\n").await?;
|
stdout.write_all(b"\n").await?;
|
||||||
|
stdout.flush().await?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let request_id = req.id;
|
let request_id = req.id.clone();
|
||||||
|
|
||||||
match handle_request(req, &root).await {
|
match handle_request(&req, &root).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let resp = Response {
|
let resp = RpcResponse::new(request_id, result);
|
||||||
id: request_id,
|
|
||||||
result,
|
|
||||||
};
|
|
||||||
let resp_str = serde_json::to_string(&resp)?;
|
let resp_str = serde_json::to_string(&resp)?;
|
||||||
stdout.write_all(resp_str.as_bytes()).await?;
|
stdout.write_all(resp_str.as_bytes()).await?;
|
||||||
stdout.write_all(b"\n").await?;
|
stdout.write_all(b"\n").await?;
|
||||||
|
stdout.flush().await?;
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let err_resp = ErrorResponse {
|
let err_resp = RpcErrorResponse::new(request_id, error);
|
||||||
id: request_id,
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
let resp_str = serde_json::to_string(&err_resp)?;
|
let resp_str = serde_json::to_string(&err_resp)?;
|
||||||
stdout.write_all(resp_str.as_bytes()).await?;
|
stdout.write_all(resp_str.as_bytes()).await?;
|
||||||
stdout.write_all(b"\n").await?;
|
stdout.write_all(b"\n").await?;
|
||||||
|
stdout.flush().await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ crossterm = { workspace = true }
|
|||||||
tui-textarea = { workspace = true }
|
tui-textarea = { workspace = true }
|
||||||
textwrap = { workspace = true }
|
textwrap = { workspace = true }
|
||||||
unicode-width = "0.1"
|
unicode-width = "0.1"
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ pub struct ChatApp {
|
|||||||
available_themes: Vec<String>, // Cached list of theme names
|
available_themes: Vec<String>, // Cached list of theme names
|
||||||
selected_theme_index: usize, // Index of selected theme in browser
|
selected_theme_index: usize, // Index of selected theme in browser
|
||||||
pending_consent: Option<ConsentDialogState>, // Pending consent request
|
pending_consent: Option<ConsentDialogState>, // Pending consent request
|
||||||
|
system_status: String, // System/status messages (tool execution, status, etc)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -173,15 +174,16 @@ impl ChatApp {
|
|||||||
let mut textarea = TextArea::default();
|
let mut textarea = TextArea::default();
|
||||||
configure_textarea_defaults(&mut textarea);
|
configure_textarea_defaults(&mut textarea);
|
||||||
|
|
||||||
// Load theme based on config
|
// Load theme and provider based on config before moving `controller`.
|
||||||
let theme_name = &controller.config().ui.theme;
|
let config_guard = controller.config_async().await;
|
||||||
let theme = owlen_core::theme::get_theme(theme_name).unwrap_or_else(|| {
|
let theme_name = config_guard.ui.theme.clone();
|
||||||
|
let current_provider = config_guard.general.default_provider.clone();
|
||||||
|
drop(config_guard);
|
||||||
|
let theme = owlen_core::theme::get_theme(&theme_name).unwrap_or_else(|| {
|
||||||
eprintln!("Warning: Theme '{}' not found, using default", theme_name);
|
eprintln!("Warning: Theme '{}' not found, using default", theme_name);
|
||||||
Theme::default()
|
Theme::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
let current_provider = controller.config().general.default_provider.clone();
|
|
||||||
|
|
||||||
let app = Self {
|
let app = Self {
|
||||||
controller,
|
controller,
|
||||||
mode: InputMode::Normal,
|
mode: InputMode::Normal,
|
||||||
@@ -225,6 +227,7 @@ impl ChatApp {
|
|||||||
available_themes: Vec::new(),
|
available_themes: Vec::new(),
|
||||||
selected_theme_index: 0,
|
selected_theme_index: 0,
|
||||||
pending_consent: None,
|
pending_consent: None,
|
||||||
|
system_status: String::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((app, session_rx))
|
Ok((app, session_rx))
|
||||||
@@ -260,10 +263,16 @@ impl ChatApp {
|
|||||||
self.controller.selected_model()
|
self.controller.selected_model()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn config(&self) -> &owlen_core::config::Config {
|
// Synchronous access for UI rendering and other callers that expect an immediate Config.
|
||||||
|
pub fn config(&self) -> tokio::sync::MutexGuard<'_, owlen_core::config::Config> {
|
||||||
self.controller.config()
|
self.controller.config()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asynchronous version retained for places that already await the config.
|
||||||
|
pub async fn config_async(&self) -> tokio::sync::MutexGuard<'_, owlen_core::config::Config> {
|
||||||
|
self.controller.config_async().await
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn model_selector_items(&self) -> &[ModelSelectorItem] {
|
pub(crate) fn model_selector_items(&self) -> &[ModelSelectorItem] {
|
||||||
&self.model_selector_items
|
&self.model_selector_items
|
||||||
}
|
}
|
||||||
@@ -328,6 +337,25 @@ impl ChatApp {
|
|||||||
&mut self.textarea
|
&mut self.textarea
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn system_status(&self) -> &str {
|
||||||
|
&self.system_status
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_system_status(&mut self, status: String) {
|
||||||
|
self.system_status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append_system_status(&mut self, status: &str) {
|
||||||
|
if !self.system_status.is_empty() {
|
||||||
|
self.system_status.push_str(" | ");
|
||||||
|
}
|
||||||
|
self.system_status.push_str(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_system_status(&mut self) {
|
||||||
|
self.system_status.clear();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn command_buffer(&self) -> &str {
|
pub fn command_buffer(&self) -> &str {
|
||||||
&self.command_buffer
|
&self.command_buffer
|
||||||
}
|
}
|
||||||
@@ -463,7 +491,7 @@ impl ChatApp {
|
|||||||
self.theme = theme;
|
self.theme = theme;
|
||||||
// Save theme to config
|
// Save theme to config
|
||||||
self.controller.config_mut().ui.theme = theme_name.to_string();
|
self.controller.config_mut().ui.theme = theme_name.to_string();
|
||||||
if let Err(err) = config::save_config(self.controller.config()) {
|
if let Err(err) = config::save_config(&self.controller.config()) {
|
||||||
self.error = Some(format!("Failed to save theme config: {}", err));
|
self.error = Some(format!("Failed to save theme config: {}", err));
|
||||||
} else {
|
} else {
|
||||||
self.status = format!("Switched to theme: {}", theme_name);
|
self.status = format!("Switched to theme: {}", theme_name);
|
||||||
@@ -538,10 +566,10 @@ impl ChatApp {
|
|||||||
|
|
||||||
self.expanded_provider = Some(self.selected_provider.clone());
|
self.expanded_provider = Some(self.selected_provider.clone());
|
||||||
self.update_selected_provider_index();
|
self.update_selected_provider_index();
|
||||||
self.sync_selected_model_index();
|
self.sync_selected_model_index().await;
|
||||||
|
|
||||||
// Ensure the default model is set in the controller and config
|
// Ensure the default model is set in the controller and config (async)
|
||||||
self.controller.ensure_default_model(&self.models);
|
self.controller.ensure_default_model(&self.models).await;
|
||||||
|
|
||||||
let current_model_name = self.controller.selected_model().to_string();
|
let current_model_name = self.controller.selected_model().to_string();
|
||||||
let current_model_provider = self.controller.config().general.default_provider.clone();
|
let current_model_provider = self.controller.config().general.default_provider.clone();
|
||||||
@@ -549,7 +577,7 @@ impl ChatApp {
|
|||||||
if config_model_name.as_deref() != Some(¤t_model_name)
|
if config_model_name.as_deref() != Some(¤t_model_name)
|
||||||
|| config_model_provider != current_model_provider
|
|| config_model_provider != current_model_provider
|
||||||
{
|
{
|
||||||
if let Err(err) = config::save_config(self.controller.config()) {
|
if let Err(err) = config::save_config(&self.controller.config()) {
|
||||||
self.error = Some(format!("Failed to save config: {err}"));
|
self.error = Some(format!("Failed to save config: {err}"));
|
||||||
} else {
|
} else {
|
||||||
self.error = None;
|
self.error = None;
|
||||||
@@ -592,24 +620,74 @@ impl ChatApp {
|
|||||||
// Handle consent dialog first (highest priority)
|
// Handle consent dialog first (highest priority)
|
||||||
if let Some(consent_state) = &self.pending_consent {
|
if let Some(consent_state) = &self.pending_consent {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
KeyCode::Char('1') => {
|
||||||
// Grant consent
|
// Allow once
|
||||||
let tool_name = consent_state.tool_name.clone();
|
let tool_name = consent_state.tool_name.clone();
|
||||||
let data_types = consent_state.data_types.clone();
|
let data_types = consent_state.data_types.clone();
|
||||||
let endpoints = consent_state.endpoints.clone();
|
let endpoints = consent_state.endpoints.clone();
|
||||||
|
|
||||||
self.controller
|
self.controller.grant_consent_with_scope(
|
||||||
.grant_consent(&tool_name, data_types, endpoints);
|
&tool_name,
|
||||||
|
data_types,
|
||||||
|
endpoints,
|
||||||
|
owlen_core::consent::ConsentScope::Once,
|
||||||
|
);
|
||||||
self.pending_consent = None;
|
self.pending_consent = None;
|
||||||
self.status = format!("✓ Consent granted for {}", tool_name);
|
self.status = format!("✓ Consent granted (once) for {}", tool_name);
|
||||||
|
self.set_system_status(format!(
|
||||||
|
"✓ Consent granted (once): {}",
|
||||||
|
tool_name
|
||||||
|
));
|
||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
KeyCode::Char('2') => {
|
||||||
|
// Allow session
|
||||||
|
let tool_name = consent_state.tool_name.clone();
|
||||||
|
let data_types = consent_state.data_types.clone();
|
||||||
|
let endpoints = consent_state.endpoints.clone();
|
||||||
|
|
||||||
|
self.controller.grant_consent_with_scope(
|
||||||
|
&tool_name,
|
||||||
|
data_types,
|
||||||
|
endpoints,
|
||||||
|
owlen_core::consent::ConsentScope::Session,
|
||||||
|
);
|
||||||
|
self.pending_consent = None;
|
||||||
|
self.status = format!("✓ Consent granted (session) for {}", tool_name);
|
||||||
|
self.set_system_status(format!(
|
||||||
|
"✓ Consent granted (session): {}",
|
||||||
|
tool_name
|
||||||
|
));
|
||||||
|
return Ok(AppState::Running);
|
||||||
|
}
|
||||||
|
KeyCode::Char('3') => {
|
||||||
|
// Allow always (permanent)
|
||||||
|
let tool_name = consent_state.tool_name.clone();
|
||||||
|
let data_types = consent_state.data_types.clone();
|
||||||
|
let endpoints = consent_state.endpoints.clone();
|
||||||
|
|
||||||
|
self.controller.grant_consent_with_scope(
|
||||||
|
&tool_name,
|
||||||
|
data_types,
|
||||||
|
endpoints,
|
||||||
|
owlen_core::consent::ConsentScope::Permanent,
|
||||||
|
);
|
||||||
|
self.pending_consent = None;
|
||||||
|
self.status =
|
||||||
|
format!("✓ Consent granted (permanent) for {}", tool_name);
|
||||||
|
self.set_system_status(format!(
|
||||||
|
"✓ Consent granted (permanent): {}",
|
||||||
|
tool_name
|
||||||
|
));
|
||||||
|
return Ok(AppState::Running);
|
||||||
|
}
|
||||||
|
KeyCode::Char('4') | KeyCode::Esc => {
|
||||||
// Deny consent - clear both consent and pending tool execution to prevent retry
|
// Deny consent - clear both consent and pending tool execution to prevent retry
|
||||||
let tool_name = consent_state.tool_name.clone();
|
let tool_name = consent_state.tool_name.clone();
|
||||||
self.pending_consent = None;
|
self.pending_consent = None;
|
||||||
self.pending_tool_execution = None; // Clear to prevent infinite retry
|
self.pending_tool_execution = None; // Clear to prevent infinite retry
|
||||||
self.status = format!("✗ Consent denied for {}", tool_name);
|
self.status = format!("✗ Consent denied for {}", tool_name);
|
||||||
|
self.set_system_status(format!("✗ Consent denied: {}", tool_name));
|
||||||
self.error = Some(format!("Tool {} was blocked by user", tool_name));
|
self.error = Some(format!("Tool {} was blocked by user", tool_name));
|
||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
@@ -1532,7 +1610,7 @@ impl ChatApp {
|
|||||||
match self.controller.set_tool_enabled(tool, true).await {
|
match self.controller.set_tool_enabled(tool, true).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
if let Err(err) =
|
if let Err(err) =
|
||||||
config::save_config(self.controller.config())
|
config::save_config(&self.controller.config())
|
||||||
{
|
{
|
||||||
self.error = Some(format!(
|
self.error = Some(format!(
|
||||||
"Enabled {tool}, but failed to save config: {err}"
|
"Enabled {tool}, but failed to save config: {err}"
|
||||||
@@ -1557,7 +1635,7 @@ impl ChatApp {
|
|||||||
match self.controller.set_tool_enabled(tool, false).await {
|
match self.controller.set_tool_enabled(tool, false).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
if let Err(err) =
|
if let Err(err) =
|
||||||
config::save_config(self.controller.config())
|
config::save_config(&self.controller.config())
|
||||||
{
|
{
|
||||||
self.error = Some(format!(
|
self.error = Some(format!(
|
||||||
"Disabled {tool}, but failed to save config: {err}"
|
"Disabled {tool}, but failed to save config: {err}"
|
||||||
@@ -1619,7 +1697,8 @@ impl ChatApp {
|
|||||||
self.available_providers.get(self.selected_provider_index)
|
self.available_providers.get(self.selected_provider_index)
|
||||||
{
|
{
|
||||||
self.selected_provider = provider.clone();
|
self.selected_provider = provider.clone();
|
||||||
self.sync_selected_model_index(); // Update model selection based on new provider
|
// Update model selection based on new provider (await async)
|
||||||
|
self.sync_selected_model_index().await; // Update model selection based on new provider
|
||||||
self.mode = InputMode::ModelSelection;
|
self.mode = InputMode::ModelSelection;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1679,7 +1758,8 @@ impl ChatApp {
|
|||||||
self.selected_provider = model.provider.clone();
|
self.selected_provider = model.provider.clone();
|
||||||
self.update_selected_provider_index();
|
self.update_selected_provider_index();
|
||||||
|
|
||||||
self.controller.set_model(model_id.clone());
|
// Set the selected model asynchronously
|
||||||
|
self.controller.set_model(model_id.clone()).await;
|
||||||
self.status = format!(
|
self.status = format!(
|
||||||
"Using model: {} (provider: {})",
|
"Using model: {} (provider: {})",
|
||||||
model_label, self.selected_provider
|
model_label, self.selected_provider
|
||||||
@@ -1689,7 +1769,7 @@ impl ChatApp {
|
|||||||
Some(model_id.clone());
|
Some(model_id.clone());
|
||||||
self.controller.config_mut().general.default_provider =
|
self.controller.config_mut().general.default_provider =
|
||||||
self.selected_provider.clone();
|
self.selected_provider.clone();
|
||||||
match config::save_config(self.controller.config()) {
|
match config::save_config(&self.controller.config()) {
|
||||||
Ok(_) => self.error = None,
|
Ok(_) => self.error = None,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
self.error = Some(format!(
|
self.error = Some(format!(
|
||||||
@@ -2351,7 +2431,9 @@ impl ChatApp {
|
|||||||
let provider_cfg = if let Some(cfg) = self.controller.config().provider(provider_name) {
|
let provider_cfg = if let Some(cfg) = self.controller.config().provider(provider_name) {
|
||||||
cfg.clone()
|
cfg.clone()
|
||||||
} else {
|
} else {
|
||||||
let cfg = config::ensure_provider_config(self.controller.config_mut(), provider_name);
|
let mut guard = self.controller.config_mut();
|
||||||
|
// Pass a mutable reference directly; avoid unnecessary deref
|
||||||
|
let cfg = config::ensure_provider_config(&mut guard, provider_name);
|
||||||
cfg.clone()
|
cfg.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2403,8 +2485,9 @@ impl ChatApp {
|
|||||||
|
|
||||||
self.expanded_provider = Some(self.selected_provider.clone());
|
self.expanded_provider = Some(self.selected_provider.clone());
|
||||||
self.update_selected_provider_index();
|
self.update_selected_provider_index();
|
||||||
self.controller.ensure_default_model(&self.models);
|
// Ensure the default model is set after refreshing models (async)
|
||||||
self.sync_selected_model_index();
|
self.controller.ensure_default_model(&self.models).await;
|
||||||
|
self.sync_selected_model_index().await;
|
||||||
|
|
||||||
let current_model_name = self.controller.selected_model().to_string();
|
let current_model_name = self.controller.selected_model().to_string();
|
||||||
let current_model_provider = self.controller.config().general.default_provider.clone();
|
let current_model_provider = self.controller.config().general.default_provider.clone();
|
||||||
@@ -2412,7 +2495,7 @@ impl ChatApp {
|
|||||||
if config_model_name.as_deref() != Some(¤t_model_name)
|
if config_model_name.as_deref() != Some(¤t_model_name)
|
||||||
|| config_model_provider != current_model_provider
|
|| config_model_provider != current_model_provider
|
||||||
{
|
{
|
||||||
if let Err(err) = config::save_config(self.controller.config()) {
|
if let Err(err) = config::save_config(&self.controller.config()) {
|
||||||
self.error = Some(format!("Failed to save config: {err}"));
|
self.error = Some(format!("Failed to save config: {err}"));
|
||||||
} else {
|
} else {
|
||||||
self.error = None;
|
self.error = None;
|
||||||
@@ -2537,6 +2620,13 @@ impl ChatApp {
|
|||||||
let consent_needed = self.controller.check_tools_consent_needed(&tool_calls);
|
let consent_needed = self.controller.check_tools_consent_needed(&tool_calls);
|
||||||
|
|
||||||
if !consent_needed.is_empty() {
|
if !consent_needed.is_empty() {
|
||||||
|
// If a consent dialog is already being shown, don't send another request
|
||||||
|
// Just re-queue the tool execution and wait for user response
|
||||||
|
if self.pending_consent.is_some() {
|
||||||
|
self.pending_tool_execution = Some((message_id, tool_calls));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
// Show consent for the first tool that needs it
|
// Show consent for the first tool that needs it
|
||||||
// After consent is granted, the next iteration will check remaining tools
|
// After consent is granted, the next iteration will check remaining tools
|
||||||
let (tool_name, data_types, endpoints) = consent_needed.into_iter().next().unwrap();
|
let (tool_name, data_types, endpoints) = consent_needed.into_iter().next().unwrap();
|
||||||
@@ -2555,6 +2645,11 @@ impl ChatApp {
|
|||||||
|
|
||||||
// Show tool execution status
|
// Show tool execution status
|
||||||
self.status = format!("🔧 Executing {} tool(s)...", tool_calls.len());
|
self.status = format!("🔧 Executing {} tool(s)...", tool_calls.len());
|
||||||
|
|
||||||
|
// Show tool names in system output
|
||||||
|
let tool_names: Vec<String> = tool_calls.iter().map(|tc| tc.name.clone()).collect();
|
||||||
|
self.set_system_status(format!("🔧 Executing tools: {}", tool_names.join(", ")));
|
||||||
|
|
||||||
self.start_loading_animation();
|
self.start_loading_animation();
|
||||||
|
|
||||||
// Execute tools and get the result
|
// Execute tools and get the result
|
||||||
@@ -2569,6 +2664,7 @@ impl ChatApp {
|
|||||||
}) => {
|
}) => {
|
||||||
// Tool execution succeeded, spawn stream handler for continuation
|
// Tool execution succeeded, spawn stream handler for continuation
|
||||||
self.status = "Tool results sent. Generating response...".to_string();
|
self.status = "Tool results sent. Generating response...".to_string();
|
||||||
|
self.set_system_status("✓ Tools executed successfully".to_string());
|
||||||
self.spawn_stream(response_id, stream);
|
self.spawn_stream(response_id, stream);
|
||||||
match self.controller.mark_stream_placeholder(response_id, "▌") {
|
match self.controller.mark_stream_placeholder(response_id, "▌") {
|
||||||
Ok(_) => self.error = None,
|
Ok(_) => self.error = None,
|
||||||
@@ -2582,19 +2678,22 @@ impl ChatApp {
|
|||||||
// Tool execution complete without streaming (shouldn't happen in streaming mode)
|
// Tool execution complete without streaming (shouldn't happen in streaming mode)
|
||||||
self.stop_loading_animation();
|
self.stop_loading_animation();
|
||||||
self.status = "✓ Tool execution complete".to_string();
|
self.status = "✓ Tool execution complete".to_string();
|
||||||
|
self.set_system_status("✓ Tool execution complete".to_string());
|
||||||
self.error = None;
|
self.error = None;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
self.stop_loading_animation();
|
self.stop_loading_animation();
|
||||||
self.status = "Tool execution failed".to_string();
|
self.status = "Tool execution failed".to_string();
|
||||||
|
self.set_system_status(format!("❌ Tool execution failed: {}", err));
|
||||||
self.error = Some(format!("Tool execution failed: {}", err));
|
self.error = Some(format!("Tool execution failed: {}", err));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_selected_model_index(&mut self) {
|
// Updated to async to allow awaiting async controller calls
|
||||||
|
async fn sync_selected_model_index(&mut self) {
|
||||||
self.expanded_provider = Some(self.selected_provider.clone());
|
self.expanded_provider = Some(self.selected_provider.clone());
|
||||||
self.rebuild_model_selector_items();
|
self.rebuild_model_selector_items();
|
||||||
|
|
||||||
@@ -2616,7 +2715,8 @@ impl ChatApp {
|
|||||||
|
|
||||||
if let Some(model) = self.selected_model_info().cloned() {
|
if let Some(model) = self.selected_model_info().cloned() {
|
||||||
self.selected_provider = model.provider.clone();
|
self.selected_provider = model.provider.clone();
|
||||||
self.controller.set_model(model.id.clone());
|
// Set the selected model asynchronously
|
||||||
|
self.controller.set_model(model.id.clone()).await;
|
||||||
self.controller.config_mut().general.default_model = Some(model.id.clone());
|
self.controller.config_mut().general.default_model = Some(model.id.clone());
|
||||||
self.controller.config_mut().general.default_provider =
|
self.controller.config_mut().general.default_provider =
|
||||||
self.selected_provider.clone();
|
self.selected_provider.clone();
|
||||||
@@ -2627,7 +2727,7 @@ impl ChatApp {
|
|||||||
self.update_selected_provider_index();
|
self.update_selected_provider_index();
|
||||||
|
|
||||||
if config_updated {
|
if config_updated {
|
||||||
if let Err(err) = config::save_config(self.controller.config()) {
|
if let Err(err) = config::save_config(&self.controller.config()) {
|
||||||
self.error = Some(format!("Failed to save config: {err}"));
|
self.error = Some(format!("Failed to save config: {err}"));
|
||||||
} else {
|
} else {
|
||||||
self.error = None;
|
self.error = None;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub mod chat_app;
|
|||||||
pub mod code_app;
|
pub mod code_app;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
pub mod tui_controller;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|
||||||
pub use chat_app::{ChatApp, SessionEvent};
|
pub use chat_app::{ChatApp, SessionEvent};
|
||||||
|
|||||||
44
crates/owlen-tui/src/tui_controller.rs
Normal file
44
crates/owlen-tui/src/tui_controller.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use owlen_core::ui::UiController;
|
||||||
|
use tokio::sync::{mpsc, oneshot};
|
||||||
|
|
||||||
|
/// A request sent from the UiController to the TUI event loop.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum TuiRequest {
|
||||||
|
Confirm {
|
||||||
|
prompt: String,
|
||||||
|
tx: oneshot::Sender<bool>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An implementation of the UiController trait for the TUI.
|
||||||
|
/// It uses channels to communicate with the main ChatApp event loop.
|
||||||
|
pub struct TuiController {
|
||||||
|
tx: mpsc::UnboundedSender<TuiRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TuiController {
|
||||||
|
pub fn new(tx: mpsc::UnboundedSender<TuiRequest>) -> Self {
|
||||||
|
Self { tx }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UiController for TuiController {
|
||||||
|
async fn confirm(&self, prompt: &str) -> bool {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
let request = TuiRequest::Confirm {
|
||||||
|
prompt: prompt.to_string(),
|
||||||
|
tx,
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.tx.send(request).is_err() {
|
||||||
|
// Receiver was dropped, so we can't get confirmation.
|
||||||
|
// Default to false for safety.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the response from the TUI.
|
||||||
|
rx.await.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,7 +61,8 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
constraints.push(Constraint::Length(input_height)); // Input
|
constraints.push(Constraint::Length(input_height)); // Input
|
||||||
constraints.push(Constraint::Length(3)); // Status
|
constraints.push(Constraint::Length(5)); // System/Status output (3 lines content + 2 borders)
|
||||||
|
constraints.push(Constraint::Length(3)); // Mode and shortcuts bar
|
||||||
|
|
||||||
let layout = Layout::default()
|
let layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
@@ -83,6 +84,9 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
|
|||||||
render_input(frame, layout[idx], app);
|
render_input(frame, layout[idx], app);
|
||||||
idx += 1;
|
idx += 1;
|
||||||
|
|
||||||
|
render_system_output(frame, layout[idx], app);
|
||||||
|
idx += 1;
|
||||||
|
|
||||||
render_status(frame, layout[idx], app);
|
render_status(frame, layout[idx], app);
|
||||||
|
|
||||||
// Render consent dialog with highest priority (always on top)
|
// Render consent dialog with highest priority (always on top)
|
||||||
@@ -973,6 +977,47 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_system_output(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||||
|
let theme = app.theme();
|
||||||
|
let system_status = app.system_status();
|
||||||
|
|
||||||
|
// Priority: system_status > error > status > "Ready"
|
||||||
|
let display_message = if !system_status.is_empty() {
|
||||||
|
system_status.to_string()
|
||||||
|
} else if let Some(error) = app.error_message() {
|
||||||
|
format!("Error: {}", error)
|
||||||
|
} else {
|
||||||
|
let status = app.status_message();
|
||||||
|
if status.is_empty() || status == "Ready" {
|
||||||
|
"Ready".to_string()
|
||||||
|
} else {
|
||||||
|
status.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a simple paragraph with wrapping enabled
|
||||||
|
let line = Line::from(Span::styled(
|
||||||
|
display_message,
|
||||||
|
Style::default().fg(theme.info),
|
||||||
|
));
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(line)
|
||||||
|
.style(Style::default().bg(theme.background))
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.title(Span::styled(
|
||||||
|
" System/Status ",
|
||||||
|
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(theme.unfocused_panel_border))
|
||||||
|
.style(Style::default().bg(theme.background).fg(theme.text)),
|
||||||
|
)
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
fn calculate_wrapped_line_count<'a, I>(lines: I, available_width: u16) -> usize
|
fn calculate_wrapped_line_count<'a, I>(lines: I, available_width: u16) -> usize
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item = &'a str>,
|
I: IntoIterator<Item = &'a str>,
|
||||||
@@ -1021,15 +1066,9 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
|||||||
InputMode::ThemeBrowser => (" THEMES", theme.mode_help),
|
InputMode::ThemeBrowser => (" THEMES", theme.mode_help),
|
||||||
};
|
};
|
||||||
|
|
||||||
let status_message = if let Some(error) = app.error_message() {
|
|
||||||
format!("Error: {}", error)
|
|
||||||
} else {
|
|
||||||
app.status_message().to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let help_text = "i:Input :m:Model :n:New :c:Clear :h:Help q:Quit";
|
let help_text = "i:Input :m:Model :n:New :c:Clear :h:Help q:Quit";
|
||||||
|
|
||||||
let left_spans = vec![
|
let spans = vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!(" {} ", mode_text),
|
format!(" {} ", mode_text),
|
||||||
Style::default()
|
Style::default()
|
||||||
@@ -1037,23 +1076,11 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
|||||||
.bg(mode_bg_color)
|
.bg(mode_bg_color)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Span::styled(
|
Span::styled(" ", Style::default().fg(theme.text)),
|
||||||
format!(" | {} ", status_message),
|
|
||||||
Style::default().fg(theme.text),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
let right_spans = vec![
|
|
||||||
Span::styled(" Help: ", Style::default().fg(theme.text)),
|
|
||||||
Span::styled(help_text, Style::default().fg(theme.info)),
|
Span::styled(help_text, Style::default().fg(theme.info)),
|
||||||
];
|
];
|
||||||
|
|
||||||
let layout = Layout::default()
|
let paragraph = Paragraph::new(Line::from(spans))
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
let left_paragraph = Paragraph::new(Line::from(left_spans))
|
|
||||||
.alignment(Alignment::Left)
|
.alignment(Alignment::Left)
|
||||||
.style(Style::default().bg(theme.status_background).fg(theme.text))
|
.style(Style::default().bg(theme.status_background).fg(theme.text))
|
||||||
.block(
|
.block(
|
||||||
@@ -1063,18 +1090,7 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
|||||||
.style(Style::default().bg(theme.status_background).fg(theme.text)),
|
.style(Style::default().bg(theme.status_background).fg(theme.text)),
|
||||||
);
|
);
|
||||||
|
|
||||||
let right_paragraph = Paragraph::new(Line::from(right_spans))
|
frame.render_widget(paragraph, area);
|
||||||
.alignment(Alignment::Right)
|
|
||||||
.style(Style::default().bg(theme.status_background).fg(theme.text))
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(theme.unfocused_panel_border))
|
|
||||||
.style(Style::default().bg(theme.status_background).fg(theme.text)),
|
|
||||||
);
|
|
||||||
|
|
||||||
frame.render_widget(left_paragraph, layout[0]);
|
|
||||||
frame.render_widget(right_paragraph, layout[1]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) {
|
fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||||
@@ -1264,7 +1280,7 @@ fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
// Add prompt
|
// Add prompt
|
||||||
lines.push(Line::from(""));
|
lines.push(Line::from(""));
|
||||||
lines.push(Line::from(vec![Span::styled(
|
lines.push(Line::from(vec![Span::styled(
|
||||||
"Allow this tool to execute?",
|
"Choose consent scope:",
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.focused_panel_border)
|
.fg(theme.focused_panel_border)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
@@ -1272,21 +1288,60 @@ fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
lines.push(Line::from(""));
|
lines.push(Line::from(""));
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
"[Y] ",
|
"[1] ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw("Allow once "),
|
||||||
|
Span::styled(
|
||||||
|
"- Grant only for this operation",
|
||||||
|
Style::default().fg(theme.placeholder),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
"[2] ",
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Green)
|
.fg(Color::Green)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Span::raw("Allow "),
|
Span::raw("Allow session "),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
"[N] ",
|
"- Grant for current session",
|
||||||
|
Style::default().fg(theme.placeholder),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
"[3] ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw("Allow always "),
|
||||||
|
Span::styled(
|
||||||
|
"- Grant permanently",
|
||||||
|
Style::default().fg(theme.placeholder),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
"[4] ",
|
||||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Span::raw("Deny "),
|
Span::raw("Deny "),
|
||||||
|
Span::styled(
|
||||||
|
"- Reject this operation",
|
||||||
|
Style::default().fg(theme.placeholder),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
"[Esc] ",
|
"[Esc] ",
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Yellow)
|
.fg(Color::DarkGray)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Span::raw("Cancel"),
|
Span::raw("Cancel"),
|
||||||
|
|||||||
Reference in New Issue
Block a user