Apply recent changes

This commit is contained in:
2025-10-09 11:33:27 +02:00
parent d002d35bde
commit fe414d49e6
28 changed files with 2106 additions and 634 deletions

View File

@@ -35,7 +35,7 @@ tui-textarea = "0.6"
# HTTP client and JSON handling
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_json = { version = "1.0" }
# Utilities
uuid = { version = "1.0", features = ["v4", "serde"] }

View File

@@ -17,7 +17,7 @@ use crossterm::{
};
use ratatui::{backend::CrosstermBackend, Terminal};
#[tokio::main]
#[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<()> {
let matches = Command::new("owlen-code")
.about("OWLEN Code Mode - TUI optimized for programming assistance")
@@ -32,6 +32,8 @@ async fn main() -> Result<()> {
.get_matches();
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") {
config.general.default_model = Some(model.clone());
@@ -56,7 +58,15 @@ async fn main() -> Result<()> {
let storage = Arc::new(StorageManager::new().await?);
// 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?;
app.inner_mut().initialize_models().await?;
@@ -76,7 +86,7 @@ async fn main() -> Result<()> {
cancellation_token.cancel();
event_handle.await?;
config::save_config(app.inner().config())?;
config::save_config(&app.inner().config())?;
disable_raw_mode()?;
execute!(

View File

@@ -1,9 +1,9 @@
//! OWLEN CLI - Chat TUI client
use anyhow::Result;
use clap::{Arg, Command};
use owlen_core::{session::SessionController, storage::StorageManager};
use owlen_ollama::OllamaProvider;
use owlen_tui::tui_controller::{TuiController, TuiRequest};
use owlen_tui::{config, ui, AppState, ChatApp, Event, EventHandler, SessionEvent};
use std::io;
use std::sync::Arc;
@@ -15,49 +15,41 @@ use crossterm::{
execute,
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<()> {
let matches = Command::new("owlen")
.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();
// (imports completed above)
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") {
config.general.default_model = Some(model.clone());
}
// Prepare provider from configuration
let provider_name = config.general.default_provider.clone();
let provider_cfg = config::ensure_provider_config(&mut config, &provider_name).clone();
let (tui_tx, _tui_rx) = mpsc::unbounded_channel::<TuiRequest>();
let tui_controller = Arc::new(TuiController::new(tui_tx));
// 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();
if provider_type != "ollama" && provider_type != "ollama-cloud" {
anyhow::bail!(
"Unsupported provider type '{}' configured for provider '{}'",
provider_cfg.provider_type,
provider_name
provider_name,
);
}
let provider = Arc::new(OllamaProvider::from_config(
&provider_cfg,
Some(&config.general),
Some(&cfg.general),
)?);
let storage = Arc::new(StorageManager::new().await?);
// Chat client - code execution tools disabled (only available in code client)
let controller = SessionController::new(provider, config.clone(), storage.clone(), false)?;
let controller =
SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?;
let (mut app, mut session_rx) = ChatApp::new(controller).await?;
app.initialize_models().await?;
@@ -86,7 +78,7 @@ async fn main() -> Result<()> {
event_handle.await?;
// Persist configuration updates (e.g., selected model)
config::save_config(app.config())?;
config::save_config(&app.config())?;
disable_raw_mode()?;
execute!(

View File

@@ -9,20 +9,20 @@ homepage.workspace = true
description = "Core traits and types for OWLEN LLM client"
[dependencies]
anyhow = "1.0.75"
anyhow = { workspace = true }
log = "0.4.20"
serde = { version = "1.0.188", features = ["derive"] }
serde_json = "1.0.105"
thiserror = "1.0.48"
tokio = { version = "1.32.0", features = ["full"] }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
unicode-segmentation = "1.11"
unicode-width = "0.1"
uuid = { version = "1.4.1", features = ["v4", "serde"] }
textwrap = "0.16.0"
futures = "0.3.28"
async-trait = "0.1.73"
toml = "0.8.0"
shellexpand = "3.1.0"
uuid = { workspace = true }
textwrap = { workspace = true }
futures = { workspace = true }
async-trait = { workspace = true }
toml = { workspace = true }
shellexpand = { workspace = true }
dirs = "5.0"
ratatui = { workspace = true }
tempfile = { workspace = true }
@@ -33,6 +33,7 @@ aes-gcm = { workspace = true }
ring = { workspace = true }
keyring = { workspace = true }
chrono = { workspace = true }
crossterm = { workspace = true }
urlencoding = { workspace = true }
rpassword = { workspace = true }
sqlx = { workspace = true }

View File

@@ -289,7 +289,12 @@ impl SecuritySettings {
}
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(),
]
}
}

View File

@@ -13,10 +13,23 @@ pub struct ConsentRequest {
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)]
pub struct ConsentRecord {
pub tool_name: String,
pub granted: bool,
pub scope: ConsentScope,
pub timestamp: DateTime<Utc>,
pub data_types: Vec<String>,
pub external_endpoints: Vec<String>,
@@ -24,7 +37,17 @@ pub struct ConsentRecord {
#[derive(Serialize, Deserialize, Default)]
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 {
@@ -36,19 +59,24 @@ impl ConsentManager {
pub fn from_vault(vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Self {
let guard = vault.lock().expect("Vault mutex poisoned");
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())
{
return Self { records };
return Self {
permanent_records,
session_records: HashMap::new(),
once_records: HashMap::new(),
pending_requests: HashMap::new(),
};
}
}
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<()> {
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
.settings_mut()
.insert("consent_records".to_string(), consent_json);
@@ -61,24 +89,60 @@ impl ConsentManager {
tool_name: &str,
data_types: Vec<String>,
endpoints: Vec<String>,
) -> Result<bool> {
if let Some(existing) = self.records.get(tool_name) {
return Ok(existing.granted);
) -> Result<ConsentScope> {
// Check if already granted permanently
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 {
tool_name: tool_name.to_string(),
granted: consent,
scope: scope.clone(),
timestamp: Utc::now(),
data_types,
external_endpoints: endpoints,
};
self.records.insert(tool_name.to_string(), record);
// Note: Caller should persist to vault after this call
Ok(consent)
// Store in appropriate location
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 | ConsentScope::Denied => {
// Don't store, just return the decision
}
}
Ok(scope)
}
/// Grant consent programmatically (for TUI or automated flows)
@@ -87,15 +151,38 @@ impl ConsentManager {
tool_name: &str,
data_types: 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 {
tool_name: tool_name.to_string(),
granted: true,
scope: scope.clone(),
timestamp: Utc::now(),
data_types,
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)
@@ -110,21 +197,44 @@ impl ConsentManager {
}
pub fn has_consent(&self, tool_name: &str) -> bool {
self.records
// Check permanent first, then session, then once
self.permanent_records
.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)
}
/// 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) {
if let Some(record) = self.records.get_mut(tool_name) {
record.granted = false;
record.timestamp = Utc::now();
}
self.permanent_records.remove(tool_name);
self.session_records.remove(tool_name);
self.once_records.remove(tool_name);
}
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)
@@ -146,27 +256,40 @@ impl ConsentManager {
tool_name: &str,
data_types: &[String],
endpoints: &[String],
) -> Result<bool> {
// TEMPORARY: Auto-grant consent when not in a proper terminal (TUI mode)
) -> Result<ConsentScope> {
// TEMPORARY: Auto-grant session consent when not in a proper terminal (TUI mode)
// TODO: Integrate consent UI into the TUI event loop
use std::io::IsTerminal;
if !io::stdin().is_terminal() || std::env::var("OWLEN_AUTO_CONSENT").is_ok() {
eprintln!("Auto-granting consent for {} (TUI mode)", tool_name);
return Ok(true);
eprintln!("Auto-granting session consent for {} (TUI mode)", tool_name);
return Ok(ConsentScope::Session);
}
println!("=== PRIVACY CONSENT REQUIRED ===");
println!("Tool: {}", tool_name);
println!("Data to be sent: {}", data_types.join(", "));
println!("External endpoints: {}", endpoints.join(", "));
println!("Do you consent to this data transmission? (y/N)");
print!("> ");
println!("\n╔══════════════════════════════════════════════════╗");
println!("║ 🔒 PRIVACY CONSENT REQUIRED 🔒 ║");
println!("╚══════════════════════════════════════════════════╝");
println!();
println!("Tool: {}", tool_name);
println!("Data: {}", data_types.join(", "));
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()?;
let mut input = String::new();
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),
}
}
}

View File

@@ -79,4 +79,7 @@ pub enum Error {
#[error("Not implemented: {0}")]
NotImplemented(String),
#[error("Permission denied: {0}")]
PermissionDenied(String),
}

View File

@@ -17,7 +17,19 @@ pub struct RemoteMcpClient;
impl RemoteMcpClient {
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
)))
}
}
}

View 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());
}
}

View File

@@ -10,6 +10,10 @@ use std::sync::Arc;
use std::time::Duration;
pub mod client;
pub mod factory;
pub mod permission;
pub mod protocol;
pub mod remote_client;
/// Descriptor for a tool exposed over MCP
#[derive(Debug, Clone, Serialize, Deserialize)]

View 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());
}
}

View 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\""));
}
}

View 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 alreadybuilt 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

View File

@@ -109,3 +109,89 @@ impl Tool for ResourcesGetTool {
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"))
}
}
}

View File

@@ -1,8 +1,9 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::time::Duration;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::Value;
pub mod code_exec;
pub mod fs_tools;
@@ -10,6 +11,13 @@ pub mod registry;
pub mod web_search;
pub mod web_search_detailed;
// Reexport tool structs for convenient cratelevel 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]
pub trait Tool: Send + Sync {
fn name(&self) -> &'static str;
@@ -25,30 +33,42 @@ pub trait Tool: Send + Sync {
async fn execute(&self, args: Value) -> Result<ToolResult>;
}
#[derive(Debug, Clone)]
pub struct ToolResult {
pub success: bool,
pub cancelled: bool,
pub output: Value,
pub duration: std::time::Duration,
pub metadata: HashMap<String, String>,
pub duration: Duration,
}
impl ToolResult {
pub fn success(output: Value) -> Self {
Self {
success: true,
cancelled: false,
output,
duration: std::time::Duration::from_millis(0),
metadata: HashMap::new(),
duration: Duration::from_millis(0),
}
}
pub fn error(message: &str) -> Self {
Self {
success: false,
output: serde_json::json!({ "error": message }),
duration: std::time::Duration::from_millis(0),
cancelled: false,
output: json!({ "error": message }),
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),
}
}
}

View File

@@ -4,22 +4,22 @@ use std::sync::Arc;
use anyhow::{Context, Result};
use serde_json::Value;
use super::Tool;
use super::{Tool, ToolResult};
use crate::config::Config;
use crate::ui::UiController;
pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn Tool>>,
}
impl Default for ToolRegistry {
fn default() -> Self {
Self::new()
}
config: Arc<tokio::sync::Mutex<Config>>,
ui: Arc<dyn UiController>,
}
impl ToolRegistry {
pub fn new() -> Self {
pub fn new(config: Arc<tokio::sync::Mutex<Config>>, ui: Arc<dyn UiController>) -> Self {
Self {
tools: HashMap::new(),
config,
ui,
}
}
@@ -40,10 +40,39 @@ impl ToolRegistry {
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
.get(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
}

View File

@@ -351,6 +351,42 @@ pub fn find_prev_word_boundary(line: &str, col: usize) -> Option<usize> {
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)]
mod tests {
use super::*;

View 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"));
}

View 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
}

View 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
);
}

View File

@@ -9,3 +9,4 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
path-clean = "1.0"
owlen-core = { path = "../owlen-core" }

View File

@@ -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 serde::{Deserialize, Serialize};
use serde::Deserialize;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
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)]
struct FileArgs {
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() {
"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" => {
let args: FileArgs = serde_json::from_value(req.params).map_err(|e| JsonRpcError {
code: -32602,
message: format!("Invalid params: {}", e),
})?;
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_list(&args.path, root).await
}
"resources/get" => {
let args: FileArgs = serde_json::from_value(req.params).map_err(|e| JsonRpcError {
code: -32602,
message: format!("Invalid params: {}", e),
})?;
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_get(&args.path, root).await
}
_ => Err(JsonRpcError {
code: -32601,
message: "Method not found".to_string(),
}),
"resources/write" => {
let params = req
.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 = if path.is_absolute() {
path.strip_prefix("/")
.map_err(|_| JsonRpcError {
code: -32602,
message: "Invalid path".to_string(),
})?
.map_err(|_| RpcError::invalid_params("Invalid path"))?
.to_path_buf()
} else {
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();
if !full_path.starts_with(root) {
return Err(JsonRpcError {
code: -32602,
message: "Path traversal detected".to_string(),
});
return Err(RpcError::path_traversal());
}
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 entries = fs::read_dir(full_path).map_err(|e| JsonRpcError {
code: -32000,
message: format!("Failed to read directory: {}", e),
let entries = fs::read_dir(full_path).map_err(|e| {
RpcError::new(
ErrorCode::RESOURCE_NOT_FOUND,
format!("Failed to read directory: {}", e),
)
})?;
let mut result = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| JsonRpcError {
code: -32000,
message: format!("Failed to read directory entry: {}", e),
let entry = entry.map_err(|e| {
RpcError::internal_error(format!("Failed to read directory entry: {}", e))
})?;
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))
}
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 content = fs::read_to_string(full_path).map_err(|e| JsonRpcError {
code: -32000,
message: format!("Failed to read file: {}", e),
let content = fs::read_to_string(full_path).map_err(|e| {
RpcError::new(
ErrorCode::RESOURCE_NOT_FOUND,
format!("Failed to read file: {}", e),
)
})?;
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]
async fn main() -> anyhow::Result<()> {
let root = env::current_dir()?;
@@ -128,43 +200,37 @@ async fn main() -> anyhow::Result<()> {
break;
}
Ok(_) => {
let req: Request = match serde_json::from_str(&line) {
let req: RpcRequest = match serde_json::from_str(&line) {
Ok(req) => req,
Err(e) => {
let err_resp = ErrorResponse {
id: 0,
error: JsonRpcError {
code: -32700,
message: format!("Parse error: {}", e),
},
};
let err_resp = RpcErrorResponse::new(
RequestId::Number(0),
RpcError::parse_error(format!("Parse error: {}", e)),
);
let resp_str = serde_json::to_string(&err_resp)?;
stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
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) => {
let resp = Response {
id: request_id,
result,
};
let resp = RpcResponse::new(request_id, result);
let resp_str = serde_json::to_string(&resp)?;
stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
Err(error) => {
let err_resp = ErrorResponse {
id: request_id,
error,
};
let err_resp = RpcErrorResponse::new(request_id, error);
let resp_str = serde_json::to_string(&err_resp)?;
stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
}
}

View File

@@ -18,6 +18,7 @@ crossterm = { workspace = true }
tui-textarea = { workspace = true }
textwrap = { workspace = true }
unicode-width = "0.1"
async-trait = "0.1"
# Async runtime
tokio = { workspace = true }

View File

@@ -155,6 +155,7 @@ pub struct ChatApp {
available_themes: Vec<String>, // Cached list of theme names
selected_theme_index: usize, // Index of selected theme in browser
pending_consent: Option<ConsentDialogState>, // Pending consent request
system_status: String, // System/status messages (tool execution, status, etc)
}
#[derive(Clone, Debug)]
@@ -173,15 +174,16 @@ impl ChatApp {
let mut textarea = TextArea::default();
configure_textarea_defaults(&mut textarea);
// Load theme based on config
let theme_name = &controller.config().ui.theme;
let theme = owlen_core::theme::get_theme(theme_name).unwrap_or_else(|| {
// Load theme and provider based on config before moving `controller`.
let config_guard = controller.config_async().await;
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);
Theme::default()
});
let current_provider = controller.config().general.default_provider.clone();
let app = Self {
controller,
mode: InputMode::Normal,
@@ -225,6 +227,7 @@ impl ChatApp {
available_themes: Vec::new(),
selected_theme_index: 0,
pending_consent: None,
system_status: String::new(),
};
Ok((app, session_rx))
@@ -260,10 +263,16 @@ impl ChatApp {
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()
}
// 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] {
&self.model_selector_items
}
@@ -328,6 +337,25 @@ impl ChatApp {
&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 {
&self.command_buffer
}
@@ -463,7 +491,7 @@ impl ChatApp {
self.theme = theme;
// Save theme to config
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));
} else {
self.status = format!("Switched to theme: {}", theme_name);
@@ -538,10 +566,10 @@ impl ChatApp {
self.expanded_provider = Some(self.selected_provider.clone());
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
self.controller.ensure_default_model(&self.models);
// Ensure the default model is set in the controller and config (async)
self.controller.ensure_default_model(&self.models).await;
let current_model_name = self.controller.selected_model().to_string();
let current_model_provider = self.controller.config().general.default_provider.clone();
@@ -549,7 +577,7 @@ impl ChatApp {
if config_model_name.as_deref() != Some(&current_model_name)
|| 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}"));
} else {
self.error = None;
@@ -592,24 +620,74 @@ impl ChatApp {
// Handle consent dialog first (highest priority)
if let Some(consent_state) = &self.pending_consent {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
// Grant consent
KeyCode::Char('1') => {
// Allow once
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(&tool_name, data_types, endpoints);
self.controller.grant_consent_with_scope(
&tool_name,
data_types,
endpoints,
owlen_core::consent::ConsentScope::Once,
);
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);
}
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
let tool_name = consent_state.tool_name.clone();
self.pending_consent = None;
self.pending_tool_execution = None; // Clear to prevent infinite retry
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));
return Ok(AppState::Running);
}
@@ -1532,7 +1610,7 @@ impl ChatApp {
match self.controller.set_tool_enabled(tool, true).await {
Ok(_) => {
if let Err(err) =
config::save_config(self.controller.config())
config::save_config(&self.controller.config())
{
self.error = Some(format!(
"Enabled {tool}, but failed to save config: {err}"
@@ -1557,7 +1635,7 @@ impl ChatApp {
match self.controller.set_tool_enabled(tool, false).await {
Ok(_) => {
if let Err(err) =
config::save_config(self.controller.config())
config::save_config(&self.controller.config())
{
self.error = Some(format!(
"Disabled {tool}, but failed to save config: {err}"
@@ -1619,7 +1697,8 @@ impl ChatApp {
self.available_providers.get(self.selected_provider_index)
{
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;
}
}
@@ -1679,7 +1758,8 @@ impl ChatApp {
self.selected_provider = model.provider.clone();
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!(
"Using model: {} (provider: {})",
model_label, self.selected_provider
@@ -1689,7 +1769,7 @@ impl ChatApp {
Some(model_id.clone());
self.controller.config_mut().general.default_provider =
self.selected_provider.clone();
match config::save_config(self.controller.config()) {
match config::save_config(&self.controller.config()) {
Ok(_) => self.error = None,
Err(err) => {
self.error = Some(format!(
@@ -2351,7 +2431,9 @@ impl ChatApp {
let provider_cfg = if let Some(cfg) = self.controller.config().provider(provider_name) {
cfg.clone()
} 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()
};
@@ -2403,8 +2485,9 @@ impl ChatApp {
self.expanded_provider = Some(self.selected_provider.clone());
self.update_selected_provider_index();
self.controller.ensure_default_model(&self.models);
self.sync_selected_model_index();
// Ensure the default model is set after refreshing models (async)
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_provider = self.controller.config().general.default_provider.clone();
@@ -2412,7 +2495,7 @@ impl ChatApp {
if config_model_name.as_deref() != Some(&current_model_name)
|| 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}"));
} else {
self.error = None;
@@ -2537,6 +2620,13 @@ impl ChatApp {
let consent_needed = self.controller.check_tools_consent_needed(&tool_calls);
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
// After consent is granted, the next iteration will check remaining tools
let (tool_name, data_types, endpoints) = consent_needed.into_iter().next().unwrap();
@@ -2555,6 +2645,11 @@ impl ChatApp {
// Show tool execution status
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();
// Execute tools and get the result
@@ -2569,6 +2664,7 @@ impl ChatApp {
}) => {
// Tool execution succeeded, spawn stream handler for continuation
self.status = "Tool results sent. Generating response...".to_string();
self.set_system_status("✓ Tools executed successfully".to_string());
self.spawn_stream(response_id, stream);
match self.controller.mark_stream_placeholder(response_id, "") {
Ok(_) => self.error = None,
@@ -2582,19 +2678,22 @@ impl ChatApp {
// Tool execution complete without streaming (shouldn't happen in streaming mode)
self.stop_loading_animation();
self.status = "✓ Tool execution complete".to_string();
self.set_system_status("✓ Tool execution complete".to_string());
self.error = None;
Ok(())
}
Err(err) => {
self.stop_loading_animation();
self.status = "Tool execution failed".to_string();
self.set_system_status(format!("❌ Tool execution failed: {}", err));
self.error = Some(format!("Tool execution failed: {}", err));
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.rebuild_model_selector_items();
@@ -2616,7 +2715,8 @@ impl ChatApp {
if let Some(model) = self.selected_model_info().cloned() {
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_provider =
self.selected_provider.clone();
@@ -2627,7 +2727,7 @@ impl ChatApp {
self.update_selected_provider_index();
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}"));
} else {
self.error = None;

View File

@@ -16,6 +16,7 @@ pub mod chat_app;
pub mod code_app;
pub mod config;
pub mod events;
pub mod tui_controller;
pub mod ui;
pub use chat_app::{ChatApp, SessionEvent};

View 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)
}
}

View File

@@ -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(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()
.direction(Direction::Vertical)
@@ -83,6 +84,9 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
render_input(frame, layout[idx], app);
idx += 1;
render_system_output(frame, layout[idx], app);
idx += 1;
render_status(frame, layout[idx], app);
// 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
where
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),
};
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 left_spans = vec![
let spans = vec![
Span::styled(
format!(" {} ", mode_text),
Style::default()
@@ -1037,23 +1076,11 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
.bg(mode_bg_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" | {} ", status_message),
Style::default().fg(theme.text),
),
];
let right_spans = vec![
Span::styled(" Help: ", Style::default().fg(theme.text)),
Span::styled(" ", Style::default().fg(theme.text)),
Span::styled(help_text, Style::default().fg(theme.info)),
];
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
let left_paragraph = Paragraph::new(Line::from(left_spans))
let paragraph = Paragraph::new(Line::from(spans))
.alignment(Alignment::Left)
.style(Style::default().bg(theme.status_background).fg(theme.text))
.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)),
);
let right_paragraph = Paragraph::new(Line::from(right_spans))
.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]);
frame.render_widget(paragraph, area);
}
fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) {
@@ -1264,7 +1280,7 @@ fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) {
// Add prompt
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Allow this tool to execute?",
"Choose consent scope:",
Style::default()
.fg(theme.focused_panel_border)
.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(vec![
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()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("Allow "),
Span::raw("Allow session "),
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),
),
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(
"[Esc] ",
Style::default()
.fg(Color::Yellow)
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
),
Span::raw("Cancel"),