feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "boosapi-agent"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
open-agent-sdk = { path = "../open-agent-sdk" }
|
||||
boosapi-core = { path = "../core" }
|
||||
boosapi-providers = { path = "../providers" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
async-trait = "0.1"
|
||||
tracing = "0.1"
|
||||
tauri = "2"
|
||||
futures = "0.3"
|
||||
@@ -0,0 +1,609 @@
|
||||
//! BoosApiProviderBridge: adapts BoosApi's ProviderAdapter to the SDK's LLMProvider trait.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use open_agent_sdk::api::provider::ProviderRequest;
|
||||
use open_agent_sdk::api::ApiError;
|
||||
use open_agent_sdk::types::{ImageContentSource, ToolResultContentBlock};
|
||||
use open_agent_sdk::{
|
||||
ApiType, ContentBlock, LLMProvider, Message, MessageRole, ProviderResponse, SDKMessage, Usage,
|
||||
};
|
||||
|
||||
use boosapi_core::types::{
|
||||
ChatContent, ChatMessage, ChatRequest, ChatTool, ChatToolFunction, ContentPart, ImageUrl,
|
||||
ModelParamOverrides, TokenUsage, ToolCall, ToolCallFunction,
|
||||
};
|
||||
use boosapi_providers::{ProviderAdapter, ProviderRequestContext};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Bridge between BoosApi providers and the open-agent-sdk LLMProvider interface.
|
||||
pub struct BoosApiProviderBridge {
|
||||
adapter: Arc<dyn ProviderAdapter>,
|
||||
ctx: ProviderRequestContext,
|
||||
api_type: ApiType,
|
||||
model_param_overrides: Option<ModelParamOverrides>,
|
||||
app: Option<tauri::AppHandle>,
|
||||
conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
impl BoosApiProviderBridge {
|
||||
pub fn new(
|
||||
adapter: Arc<dyn ProviderAdapter>,
|
||||
ctx: ProviderRequestContext,
|
||||
provider_type: &str,
|
||||
) -> Result<Self, String> {
|
||||
let api_type = match provider_type {
|
||||
"anthropic" => ApiType::AnthropicMessages,
|
||||
"openai" => ApiType::OpenAICompletions,
|
||||
"gemini" => ApiType::OpenAICompletions,
|
||||
"custom" => ApiType::OpenAICompletions,
|
||||
"openai_responses" => ApiType::OpenAICompletions,
|
||||
"deepseek" => ApiType::OpenAICompletions,
|
||||
"xai" => ApiType::OpenAICompletions,
|
||||
"glm" => ApiType::OpenAICompletions,
|
||||
"siliconflow" => ApiType::OpenAICompletions,
|
||||
other => {
|
||||
tracing::warn!(
|
||||
"Unknown provider type '{}', defaulting to OpenAI compat",
|
||||
other
|
||||
);
|
||||
ApiType::OpenAICompletions
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
adapter,
|
||||
ctx,
|
||||
api_type,
|
||||
model_param_overrides: None,
|
||||
app: None,
|
||||
conversation_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_model_param_overrides(mut self, overrides: Option<ModelParamOverrides>) -> Self {
|
||||
self.model_param_overrides = overrides;
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach a Tauri AppHandle for streaming text chunks to the frontend.
|
||||
pub fn with_app(mut self, app: tauri::AppHandle, conversation_id: String) -> Self {
|
||||
self.app = Some(app);
|
||||
self.conversation_id = Some(conversation_id);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LLMProvider for BoosApiProviderBridge {
|
||||
fn api_type(&self) -> ApiType {
|
||||
self.api_type.clone()
|
||||
}
|
||||
|
||||
async fn create_message(
|
||||
&self,
|
||||
request: ProviderRequest<'_>,
|
||||
stream_tx: Option<tokio::sync::mpsc::Sender<SDKMessage>>,
|
||||
) -> Result<ProviderResponse, ApiError> {
|
||||
let chat_request = convert_request(request, self.model_param_overrides.as_ref());
|
||||
|
||||
let mut stream = self.adapter.chat_stream(&self.ctx, chat_request);
|
||||
let mut accumulated_text = String::new();
|
||||
let mut accumulated_thinking = String::new();
|
||||
let mut final_tool_calls: Option<Vec<ToolCall>> = None;
|
||||
let mut final_usage: Option<TokenUsage> = None;
|
||||
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(chunk) => {
|
||||
if let Some(ref text) = chunk.content {
|
||||
if !text.is_empty() {
|
||||
accumulated_text.push_str(text);
|
||||
|
||||
// Emit streaming text delta via SDK channel
|
||||
// (agent.rs will forward to frontend as agent-stream-text)
|
||||
if let Some(ref tx) = stream_tx {
|
||||
let _ = tx.try_send(SDKMessage::TextDelta { text: text.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref thinking) = chunk.thinking {
|
||||
if !thinking.is_empty() {
|
||||
accumulated_thinking.push_str(thinking);
|
||||
|
||||
// Emit streaming thinking delta via SDK channel
|
||||
// (agent.rs will forward to frontend as agent-stream-thinking)
|
||||
if let Some(ref tx) = stream_tx {
|
||||
let _ = tx.try_send(SDKMessage::ThinkingDelta {
|
||||
thinking: thinking.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if chunk.tool_calls.is_some() {
|
||||
final_tool_calls.clone_from(&chunk.tool_calls);
|
||||
}
|
||||
|
||||
if chunk.usage.is_some() {
|
||||
final_usage.clone_from(&chunk.usage);
|
||||
}
|
||||
|
||||
if chunk.done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(classify_provider_error(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let usage = final_usage.unwrap_or(TokenUsage {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
});
|
||||
|
||||
let response = boosapi_core::types::ChatResponse {
|
||||
id: String::new(),
|
||||
model: String::new(),
|
||||
content: accumulated_text,
|
||||
thinking: if accumulated_thinking.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(accumulated_thinking)
|
||||
},
|
||||
usage,
|
||||
tool_calls: final_tool_calls,
|
||||
};
|
||||
|
||||
Ok(convert_response(response))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SDK ProviderRequest → BoosApi ChatRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn convert_request(
|
||||
request: ProviderRequest<'_>,
|
||||
model_param_overrides: Option<&ModelParamOverrides>,
|
||||
) -> ChatRequest {
|
||||
let messages: Vec<ChatMessage> = request
|
||||
.messages
|
||||
.iter()
|
||||
.flat_map(convert_sdk_message_to_chat_messages)
|
||||
.collect();
|
||||
|
||||
let tools: Option<Vec<ChatTool>> = request.tools.as_ref().map(|tools| {
|
||||
tools
|
||||
.iter()
|
||||
.map(|t| ChatTool {
|
||||
r#type: "function".to_string(),
|
||||
function: ChatToolFunction {
|
||||
name: t.name.clone(),
|
||||
description: Some(t.description.clone()),
|
||||
parameters: Some(t.input_schema.clone()),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let system_text: Option<String> = request.system.as_ref().map(|blocks| {
|
||||
blocks
|
||||
.iter()
|
||||
.map(|b| b.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
});
|
||||
|
||||
let mut final_messages = Vec::new();
|
||||
if let Some(sys) = system_text {
|
||||
final_messages.push(ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: ChatContent::Text(sys),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
}
|
||||
final_messages.extend(messages);
|
||||
|
||||
let force_model_max_tokens = model_param_overrides
|
||||
.and_then(|overrides| overrides.force_max_tokens)
|
||||
== Some(true);
|
||||
let max_tokens = if request.max_tokens > 0 {
|
||||
Some(request.max_tokens as u32)
|
||||
} else if force_model_max_tokens {
|
||||
model_param_overrides
|
||||
.and_then(|overrides| overrides.max_tokens)
|
||||
.or(Some(4096))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
ChatRequest {
|
||||
model: request.model.to_string(),
|
||||
messages: final_messages,
|
||||
stream: true,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
max_tokens,
|
||||
tools,
|
||||
thinking_budget: request
|
||||
.thinking
|
||||
.as_ref()
|
||||
.and_then(|t| t.budget_tokens.map(|b| b as u32)),
|
||||
thinking_level: None,
|
||||
reasoning_profile: model_param_overrides
|
||||
.and_then(|overrides| overrides.reasoning_profile.clone()),
|
||||
use_max_completion_tokens: model_param_overrides
|
||||
.and_then(|overrides| overrides.use_max_completion_tokens),
|
||||
thinking_param_style: model_param_overrides
|
||||
.and_then(|overrides| overrides.thinking_param_style.clone()),
|
||||
extra_body: model_param_overrides.and_then(|overrides| overrides.extra_body.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a single SDK Message into one or more BoosApi ChatMessages.
|
||||
/// ToolResult content blocks become separate tool-role messages.
|
||||
fn convert_sdk_message_to_chat_messages(msg: &Message) -> Vec<ChatMessage> {
|
||||
let role = match msg.role {
|
||||
MessageRole::User => "user",
|
||||
MessageRole::Assistant => "assistant",
|
||||
};
|
||||
|
||||
let mut text_parts: Vec<String> = Vec::new();
|
||||
let mut thinking_parts: Vec<String> = Vec::new();
|
||||
let mut tool_calls: Vec<ToolCall> = Vec::new();
|
||||
let mut tool_results: Vec<(String, String)> = Vec::new();
|
||||
let mut image_parts: Vec<ImageContentSource> = Vec::new();
|
||||
|
||||
for block in &msg.content {
|
||||
match block {
|
||||
ContentBlock::Text { text } => {
|
||||
text_parts.push(text.clone());
|
||||
}
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
tool_calls.push(ToolCall {
|
||||
id: id.clone(),
|
||||
call_type: "function".to_string(),
|
||||
function: ToolCallFunction {
|
||||
name: name.clone(),
|
||||
arguments: serde_json::to_string(input).unwrap_or_default(),
|
||||
},
|
||||
});
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error: _,
|
||||
} => {
|
||||
let text = content
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
ToolResultContentBlock::Text { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
tool_results.push((tool_use_id.clone(), text));
|
||||
}
|
||||
ContentBlock::Thinking { thinking, .. } => {
|
||||
if msg.role == MessageRole::Assistant && !thinking.is_empty() {
|
||||
thinking_parts.push(thinking.clone());
|
||||
}
|
||||
}
|
||||
ContentBlock::Image { source } => {
|
||||
image_parts.push(source.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
if role == "assistant" {
|
||||
let content = if text_parts.is_empty() {
|
||||
ChatContent::Text(String::new())
|
||||
} else {
|
||||
ChatContent::Text(text_parts.join(""))
|
||||
};
|
||||
|
||||
result.push(ChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content,
|
||||
reasoning_content: if thinking_parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(thinking_parts.join(""))
|
||||
},
|
||||
tool_calls: if tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_calls)
|
||||
},
|
||||
tool_call_id: None,
|
||||
});
|
||||
} else if !tool_results.is_empty() {
|
||||
for (tool_use_id, text) in tool_results {
|
||||
result.push(ChatMessage {
|
||||
role: "tool".to_string(),
|
||||
content: ChatContent::Text(text),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_use_id),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let content = if !image_parts.is_empty() {
|
||||
let mut parts: Vec<ContentPart> = text_parts
|
||||
.iter()
|
||||
.map(|t| ContentPart {
|
||||
r#type: "text".to_string(),
|
||||
text: Some(t.clone()),
|
||||
image_url: None,
|
||||
})
|
||||
.collect();
|
||||
for img in &image_parts {
|
||||
parts.push(ContentPart {
|
||||
r#type: "image_url".to_string(),
|
||||
text: None,
|
||||
image_url: Some(ImageUrl {
|
||||
url: format!("data:{};base64,{}", img.media_type, img.data),
|
||||
}),
|
||||
});
|
||||
}
|
||||
ChatContent::Multipart(parts)
|
||||
} else {
|
||||
ChatContent::Text(text_parts.join(""))
|
||||
};
|
||||
|
||||
result.push(ChatMessage {
|
||||
role: role.to_string(),
|
||||
content,
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BoosApi ChatResponse → SDK ProviderResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn convert_response(response: boosapi_core::types::ChatResponse) -> ProviderResponse {
|
||||
let mut content_blocks: Vec<ContentBlock> = Vec::new();
|
||||
|
||||
if let Some(thinking) = &response.thinking {
|
||||
if !thinking.is_empty() {
|
||||
content_blocks.push(ContentBlock::Thinking {
|
||||
thinking: thinking.clone(),
|
||||
signature: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !response.content.is_empty() {
|
||||
content_blocks.push(ContentBlock::Text {
|
||||
text: response.content.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(tool_calls) = &response.tool_calls {
|
||||
for tc in tool_calls {
|
||||
let input: Value = serde_json::from_str(&tc.function.arguments).unwrap_or(Value::Null);
|
||||
content_blocks.push(ContentBlock::ToolUse {
|
||||
id: tc.id.clone(),
|
||||
name: tc.function.name.clone(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let stop_reason = if response
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map_or(false, |tc| !tc.is_empty())
|
||||
{
|
||||
Some("tool_use".to_string())
|
||||
} else {
|
||||
Some("end_turn".to_string())
|
||||
};
|
||||
|
||||
ProviderResponse {
|
||||
message: Message {
|
||||
role: MessageRole::Assistant,
|
||||
content: content_blocks,
|
||||
},
|
||||
usage: Usage {
|
||||
input_tokens: response.usage.prompt_tokens as u64,
|
||||
output_tokens: response.usage.completion_tokens as u64,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
stop_reason,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse an HTTP status code from provider error messages like "OpenAI API error 400 Bad Request: ..."
|
||||
fn parse_http_status(err: &str) -> Option<u16> {
|
||||
for pattern in &["API error ", "error "] {
|
||||
if let Some(pos) = err.find(pattern) {
|
||||
let after = &err[pos + pattern.len()..];
|
||||
if let Some(end) = after.find(|c: char| !c.is_ascii_digit()) {
|
||||
if end > 0 {
|
||||
if let Ok(status) = after[..end].parse::<u16>() {
|
||||
if (100..600).contains(&status) {
|
||||
return Some(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Classify a provider error into the appropriate SDK ApiError variant,
|
||||
/// so the retry logic only retries truly transient errors.
|
||||
fn classify_provider_error(e: boosapi_core::error::BoosApiError) -> ApiError {
|
||||
let err_str = e.to_string();
|
||||
if let Some(status) = parse_http_status(&err_str) {
|
||||
if status == 401 || status == 403 {
|
||||
ApiError::AuthError(err_str)
|
||||
} else if status == 429 {
|
||||
ApiError::RateLimitError
|
||||
} else if (400..500).contains(&status) {
|
||||
// Client errors (400, 404, 422, etc.) are NOT retryable
|
||||
ApiError::HttpError {
|
||||
status,
|
||||
message: err_str,
|
||||
}
|
||||
} else {
|
||||
// 5xx errors are retryable via NetworkError mapping
|
||||
ApiError::NetworkError(err_str)
|
||||
}
|
||||
} else {
|
||||
ApiError::NetworkError(err_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use open_agent_sdk::api::provider::ProviderRequest;
|
||||
|
||||
fn param_overrides() -> ModelParamOverrides {
|
||||
ModelParamOverrides {
|
||||
temperature: None,
|
||||
max_tokens: Some(2048),
|
||||
top_p: None,
|
||||
frequency_penalty: None,
|
||||
use_max_completion_tokens: Some(false),
|
||||
no_system_role: None,
|
||||
force_max_tokens: None,
|
||||
thinking_param_style: Some("enable_thinking".to_string()),
|
||||
reasoning_profile: Some("siliconflow_enable_thinking".to_string()),
|
||||
reasoning_options: None,
|
||||
reasoning_default: None,
|
||||
extra_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_request_applies_non_token_model_param_overrides() {
|
||||
let messages = vec![Message {
|
||||
role: MessageRole::User,
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
}];
|
||||
let request = ProviderRequest {
|
||||
model: "deepseek-reasoner",
|
||||
max_tokens: 0,
|
||||
messages: &messages,
|
||||
system: None,
|
||||
tools: None,
|
||||
thinking: None,
|
||||
};
|
||||
|
||||
let converted = convert_request(request, Some(¶m_overrides()));
|
||||
|
||||
assert_eq!(converted.max_tokens, None);
|
||||
assert_eq!(
|
||||
converted.reasoning_profile.as_deref(),
|
||||
Some("siliconflow_enable_thinking")
|
||||
);
|
||||
assert_eq!(converted.use_max_completion_tokens, Some(false));
|
||||
assert_eq!(
|
||||
converted.thinking_param_style.as_deref(),
|
||||
Some("enable_thinking")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_request_uses_model_max_tokens_only_when_forced() {
|
||||
let messages = vec![Message {
|
||||
role: MessageRole::User,
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
}];
|
||||
let request = ProviderRequest {
|
||||
model: "deepseek-reasoner",
|
||||
max_tokens: 0,
|
||||
messages: &messages,
|
||||
system: None,
|
||||
tools: None,
|
||||
thinking: None,
|
||||
};
|
||||
let mut overrides = param_overrides();
|
||||
overrides.force_max_tokens = Some(true);
|
||||
|
||||
let converted = convert_request(request, Some(&overrides));
|
||||
|
||||
assert_eq!(converted.max_tokens, Some(2048));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_request_prefers_sdk_max_tokens_over_model_default() {
|
||||
let messages = vec![Message {
|
||||
role: MessageRole::User,
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
}];
|
||||
let request = ProviderRequest {
|
||||
model: "deepseek-reasoner",
|
||||
max_tokens: 8192,
|
||||
messages: &messages,
|
||||
system: None,
|
||||
tools: None,
|
||||
thinking: None,
|
||||
};
|
||||
|
||||
let converted = convert_request(request, Some(¶m_overrides()));
|
||||
|
||||
assert_eq!(converted.max_tokens, Some(8192));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_thinking_block_becomes_reasoning_content() {
|
||||
let message = Message {
|
||||
role: MessageRole::Assistant,
|
||||
content: vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "hidden reasoning".to_string(),
|
||||
signature: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "final answer".to_string(),
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: "call-1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: serde_json::json!({"path": "README.md"}),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let converted = convert_sdk_message_to_chat_messages(&message);
|
||||
|
||||
assert_eq!(converted.len(), 1);
|
||||
assert_eq!(
|
||||
converted[0].reasoning_content.as_deref(),
|
||||
Some("hidden reasoning")
|
||||
);
|
||||
assert!(converted[0].tool_calls.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod bridge;
|
||||
pub mod permission;
|
||||
pub mod security;
|
||||
@@ -0,0 +1,108 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Tool risk classification
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RiskLevel {
|
||||
ReadOnly,
|
||||
Write,
|
||||
Execute,
|
||||
}
|
||||
|
||||
/// Permission mode for the agent session
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PermissionMode {
|
||||
Default,
|
||||
AcceptEdits,
|
||||
FullAccess,
|
||||
}
|
||||
|
||||
impl PermissionMode {
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"accept_edits" => Self::AcceptEdits,
|
||||
"full_access" => Self::FullAccess,
|
||||
_ => Self::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the permission system decides
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PermissionAction {
|
||||
AutoAllow,
|
||||
RequireApproval,
|
||||
HardDeny,
|
||||
}
|
||||
|
||||
/// Classify a tool's risk level based on its name
|
||||
pub fn classify_tool_risk(tool_name: &str) -> RiskLevel {
|
||||
let name_lower = tool_name.to_lowercase();
|
||||
|
||||
// Execute-level tools
|
||||
if matches!(
|
||||
name_lower.as_str(),
|
||||
"bash" | "shell" | "run_command" | "execute"
|
||||
) || name_lower.contains("exec")
|
||||
|| name_lower.contains("run")
|
||||
|| name_lower.contains("bash")
|
||||
|| name_lower.contains("shell")
|
||||
{
|
||||
return RiskLevel::Execute;
|
||||
}
|
||||
|
||||
// Write-level tools
|
||||
if matches!(
|
||||
name_lower.as_str(),
|
||||
"write"
|
||||
| "edit"
|
||||
| "create"
|
||||
| "delete"
|
||||
| "rename"
|
||||
| "patch"
|
||||
| "write_file"
|
||||
| "edit_file"
|
||||
| "create_file"
|
||||
| "delete_file"
|
||||
| "move"
|
||||
| "mkdir"
|
||||
| "remove"
|
||||
) || name_lower.contains("write")
|
||||
|| name_lower.contains("edit")
|
||||
|| name_lower.contains("create")
|
||||
|| name_lower.contains("delete")
|
||||
|| name_lower.contains("patch")
|
||||
{
|
||||
return RiskLevel::Write;
|
||||
}
|
||||
|
||||
// Everything else is read-only
|
||||
RiskLevel::ReadOnly
|
||||
}
|
||||
|
||||
/// Decision matrix: given permission mode, risk level, and whether the tool
|
||||
/// is in the "always allowed" set, return the action to take.
|
||||
pub fn decide_permission(
|
||||
mode: PermissionMode,
|
||||
risk: RiskLevel,
|
||||
is_always_allowed: bool,
|
||||
) -> PermissionAction {
|
||||
// If tool was previously approved with "always allow", auto-allow
|
||||
if is_always_allowed {
|
||||
return PermissionAction::AutoAllow;
|
||||
}
|
||||
|
||||
match (mode, risk) {
|
||||
// Default mode: only read is auto-allowed
|
||||
(PermissionMode::Default, RiskLevel::ReadOnly) => PermissionAction::AutoAllow,
|
||||
(PermissionMode::Default, RiskLevel::Write) => PermissionAction::RequireApproval,
|
||||
(PermissionMode::Default, RiskLevel::Execute) => PermissionAction::RequireApproval,
|
||||
|
||||
// Accept edits: read + write auto-allowed
|
||||
(PermissionMode::AcceptEdits, RiskLevel::ReadOnly) => PermissionAction::AutoAllow,
|
||||
(PermissionMode::AcceptEdits, RiskLevel::Write) => PermissionAction::AutoAllow,
|
||||
(PermissionMode::AcceptEdits, RiskLevel::Execute) => PermissionAction::RequireApproval,
|
||||
|
||||
// Full access: everything auto-allowed
|
||||
(PermissionMode::FullAccess, _) => PermissionAction::AutoAllow,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use open_agent_sdk::PermissionDecision;
|
||||
use serde_json::Value;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Validate that a path resolves to within the cwd.
|
||||
/// For existing paths, uses canonicalize (resolves symlinks).
|
||||
/// For non-existing paths, validates the parent directory.
|
||||
pub fn validate_path_within_cwd(path: &str, cwd: &str) -> Result<PathBuf, String> {
|
||||
let target = Path::new(path);
|
||||
let cwd_path = Path::new(cwd);
|
||||
|
||||
// Canonicalize cwd (must exist)
|
||||
let cwd_canonical = cwd_path
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot resolve working directory '{}': {}", cwd, e))?;
|
||||
|
||||
// Try to resolve the target path
|
||||
let resolved = if target.is_absolute() {
|
||||
target.to_path_buf()
|
||||
} else {
|
||||
cwd_path.join(target)
|
||||
};
|
||||
|
||||
// If path exists, canonicalize it (resolves symlinks)
|
||||
if resolved.exists() {
|
||||
let canonical = resolved
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot resolve path '{}': {}", path, e))?;
|
||||
if canonical.starts_with(&cwd_canonical) {
|
||||
Ok(canonical)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Path '{}' resolves to '{}' which is outside working directory '{}'",
|
||||
path,
|
||||
canonical.display(),
|
||||
cwd_canonical.display()
|
||||
))
|
||||
}
|
||||
} else {
|
||||
// For non-existing paths, check parent directory
|
||||
if let Some(parent) = resolved.parent() {
|
||||
if parent.exists() {
|
||||
let parent_canonical = parent
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot resolve parent of '{}': {}", path, e))?;
|
||||
if parent_canonical.starts_with(&cwd_canonical) {
|
||||
Ok(resolved)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Parent of '{}' resolves outside working directory '{}'",
|
||||
path,
|
||||
cwd_canonical.display()
|
||||
))
|
||||
}
|
||||
} else {
|
||||
// Parent doesn't exist — walk up until we find an existing ancestor
|
||||
let mut ancestor = parent.to_path_buf();
|
||||
loop {
|
||||
if ancestor.exists() {
|
||||
let anc_canonical = ancestor
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot resolve ancestor: {}", e))?;
|
||||
if anc_canonical.starts_with(&cwd_canonical) {
|
||||
return Ok(resolved);
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Path '{}' is outside working directory '{}'",
|
||||
path,
|
||||
cwd_canonical.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
if !ancestor.pop() {
|
||||
return Err(format!(
|
||||
"Cannot verify path '{}' against working directory",
|
||||
path
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(format!("Invalid path: '{}'", path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a tool's path arguments are safe (within cwd).
|
||||
/// Returns Some(PermissionDecision::Deny(reason)) if unsafe, None if safe or not applicable.
|
||||
pub fn check_path_safety(tool_name: &str, input: &Value, cwd: &str) -> Option<PermissionDecision> {
|
||||
let name_lower = tool_name.to_lowercase();
|
||||
|
||||
match name_lower.as_str() {
|
||||
// Single-file path tools
|
||||
"read" | "read_file" | "write" | "write_file" | "edit" | "edit_file" | "create"
|
||||
| "create_file" | "delete" | "delete_file" | "rename" | "list_dir" | "listdir" => {
|
||||
check_single_path(input, cwd)
|
||||
}
|
||||
|
||||
// Glob tool — check base directory of pattern
|
||||
"glob" | "glob_search" => check_glob_path(input, cwd),
|
||||
|
||||
// Grep tool — optional path
|
||||
"grep" | "search" | "ripgrep" => check_grep_path(input, cwd),
|
||||
|
||||
// Bash/shell — no path checking (cwd is injected, but cd escape is Phase 4)
|
||||
"bash" | "shell" | "run_command" | "execute" => None,
|
||||
|
||||
// Unknown tools — don't block
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn check_single_path(input: &Value, cwd: &str) -> Option<PermissionDecision> {
|
||||
// Try common field names for path
|
||||
let path_str = input
|
||||
.get("path")
|
||||
.or_else(|| input.get("file_path"))
|
||||
.or_else(|| input.get("file"))
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
if let Some(path) = path_str {
|
||||
if let Err(reason) = validate_path_within_cwd(path, cwd) {
|
||||
return Some(PermissionDecision::Deny(reason));
|
||||
}
|
||||
}
|
||||
|
||||
// Also check "new_path" for rename operations
|
||||
if let Some(new_path) = input.get("new_path").and_then(|v| v.as_str()) {
|
||||
if let Err(reason) = validate_path_within_cwd(new_path, cwd) {
|
||||
return Some(PermissionDecision::Deny(reason));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn check_glob_path(input: &Value, cwd: &str) -> Option<PermissionDecision> {
|
||||
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
|
||||
if let Err(reason) = validate_path_within_cwd(path, cwd) {
|
||||
return Some(PermissionDecision::Deny(reason));
|
||||
}
|
||||
}
|
||||
|
||||
let pattern = input
|
||||
.get("pattern")
|
||||
.or_else(|| input.get("glob"))
|
||||
.and_then(|v| v.as_str())?;
|
||||
|
||||
// Extract base directory from glob pattern (non-wildcard prefix)
|
||||
let base_dir = extract_glob_base(pattern);
|
||||
|
||||
if !base_dir.is_empty() {
|
||||
if let Err(reason) = validate_path_within_cwd(&base_dir, cwd) {
|
||||
return Some(PermissionDecision::Deny(reason));
|
||||
}
|
||||
}
|
||||
// Empty base_dir means pattern starts with wildcard → relative to cwd → safe
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn check_grep_path(input: &Value, cwd: &str) -> Option<PermissionDecision> {
|
||||
// Grep path is optional — if not specified, defaults to cwd (safe)
|
||||
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
|
||||
if let Err(reason) = validate_path_within_cwd(path, cwd) {
|
||||
return Some(PermissionDecision::Deny(reason));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the non-wildcard prefix from a glob pattern as a base directory.
|
||||
/// e.g., "src/components/**/*.tsx" → "src/components"
|
||||
/// "**/*.rs" → ""
|
||||
/// "/absolute/path/to/*.txt" → "/absolute/path/to"
|
||||
fn extract_glob_base(pattern: &str) -> String {
|
||||
let mut parts = Vec::new();
|
||||
for segment in pattern.split('/') {
|
||||
if segment.contains('*')
|
||||
|| segment.contains('?')
|
||||
|| segment.contains('[')
|
||||
|| segment.contains('{')
|
||||
{
|
||||
break;
|
||||
}
|
||||
parts.push(segment);
|
||||
}
|
||||
parts.join("/")
|
||||
}
|
||||
Reference in New Issue
Block a user