mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-19 15:14:21 +01:00
feat: support tool level permission control in ui (#2133)
This commit is contained in:
@@ -21,6 +21,7 @@ use utoipa::OpenApi;
|
|||||||
super::routes::config_management::get_extensions,
|
super::routes::config_management::get_extensions,
|
||||||
super::routes::config_management::read_all_config,
|
super::routes::config_management::read_all_config,
|
||||||
super::routes::config_management::providers,
|
super::routes::config_management::providers,
|
||||||
|
super::routes::config_management::upsert_permissions,
|
||||||
super::routes::agent::get_tools,
|
super::routes::agent::get_tools,
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
@@ -31,6 +32,8 @@ use utoipa::OpenApi;
|
|||||||
super::routes::config_management::ProviderDetails,
|
super::routes::config_management::ProviderDetails,
|
||||||
super::routes::config_management::ExtensionResponse,
|
super::routes::config_management::ExtensionResponse,
|
||||||
super::routes::config_management::ExtensionQuery,
|
super::routes::config_management::ExtensionQuery,
|
||||||
|
super::routes::config_management::ToolPermission,
|
||||||
|
super::routes::config_management::UpsertPermissionsQuery,
|
||||||
ProviderMetadata,
|
ProviderMetadata,
|
||||||
ExtensionEntry,
|
ExtensionEntry,
|
||||||
ExtensionConfig,
|
ExtensionConfig,
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ use axum::{
|
|||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use goose::agents::{extension::ToolInfo, extension_manager::get_parameter_names};
|
|
||||||
use goose::config::Config;
|
use goose::config::Config;
|
||||||
use goose::config::PermissionManager;
|
use goose::config::PermissionManager;
|
||||||
use goose::{agents::Agent, model::ModelConfig, providers};
|
use goose::{agents::Agent, model::ModelConfig, providers};
|
||||||
|
use goose::{
|
||||||
|
agents::{extension::ToolInfo, extension_manager::get_parameter_names},
|
||||||
|
config::permission::PermissionLevel,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::env;
|
use std::env;
|
||||||
@@ -173,7 +176,7 @@ async fn list_providers() -> Json<Vec<ProviderList>> {
|
|||||||
("extension_name" = Option<String>, Query, description = "Optional extension name to filter tools")
|
("extension_name" = Option<String>, Query, description = "Optional extension name to filter tools")
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Tools retrieved successfully", body = Vec<Tool>),
|
(status = 200, description = "Tools retrieved successfully", body = Vec<ToolInfo>),
|
||||||
(status = 401, description = "Unauthorized - invalid secret key"),
|
(status = 401, description = "Unauthorized - invalid secret key"),
|
||||||
(status = 424, description = "Agent not initialized"),
|
(status = 424, description = "Agent not initialized"),
|
||||||
(status = 500, description = "Internal server error")
|
(status = 500, description = "Internal server error")
|
||||||
@@ -193,11 +196,13 @@ async fn get_tools(
|
|||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let config = Config::global();
|
||||||
|
let goose_mode = config.get_param("GOOSE_MODE").unwrap_or("auto".to_string());
|
||||||
let mut agent = state.agent.write().await;
|
let mut agent = state.agent.write().await;
|
||||||
let agent = agent.as_mut().ok_or(StatusCode::PRECONDITION_REQUIRED)?;
|
let agent = agent.as_mut().ok_or(StatusCode::PRECONDITION_REQUIRED)?;
|
||||||
let permission_manager = PermissionManager::default();
|
let permission_manager = PermissionManager::default();
|
||||||
|
|
||||||
let tools = agent
|
let mut tools: Vec<ToolInfo> = agent
|
||||||
.list_tools()
|
.list_tools()
|
||||||
.await
|
.await
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -210,14 +215,27 @@ async fn get_tools(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map(|tool| {
|
.map(|tool| {
|
||||||
|
let permission = permission_manager
|
||||||
|
.get_user_permission(&tool.name)
|
||||||
|
.or_else(|| {
|
||||||
|
if goose_mode == "smart_approve" {
|
||||||
|
permission_manager.get_smart_approve_permission(&tool.name)
|
||||||
|
} else if goose_mode == "approve" {
|
||||||
|
Some(PermissionLevel::AskBefore)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ToolInfo::new(
|
ToolInfo::new(
|
||||||
&tool.name,
|
&tool.name,
|
||||||
&tool.description,
|
&tool.description,
|
||||||
get_parameter_names(&tool),
|
get_parameter_names(&tool),
|
||||||
permission_manager.get_user_permission(&tool.name),
|
permission,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect::<Vec<ToolInfo>>();
|
||||||
|
tools.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
|
||||||
Ok(Json(tools))
|
Ok(Json(tools))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ use axum::{
|
|||||||
routing::{delete, get, post},
|
routing::{delete, get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use goose::agents::ExtensionConfig;
|
|
||||||
use goose::config::extensions::name_to_key;
|
|
||||||
use goose::config::Config;
|
use goose::config::Config;
|
||||||
|
use goose::config::{extensions::name_to_key, PermissionManager};
|
||||||
use goose::config::{ExtensionConfigManager, ExtensionEntry};
|
use goose::config::{ExtensionConfigManager, ExtensionEntry};
|
||||||
use goose::providers::base::ProviderMetadata;
|
use goose::providers::base::ProviderMetadata;
|
||||||
use goose::providers::providers as get_providers;
|
use goose::providers::providers as get_providers;
|
||||||
|
use goose::{agents::ExtensionConfig, config::permission::PermissionLevel};
|
||||||
use http::{HeaderMap, StatusCode};
|
use http::{HeaderMap, StatusCode};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -78,6 +78,18 @@ pub struct ProvidersResponse {
|
|||||||
pub providers: Vec<ProviderDetails>,
|
pub providers: Vec<ProviderDetails>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ToolPermission {
|
||||||
|
/// Unique identifier and name of the tool, format <extension_name>__<tool_name>
|
||||||
|
pub tool_name: String,
|
||||||
|
pub permission: PermissionLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct UpsertPermissionsQuery {
|
||||||
|
pub tool_permissions: Vec<ToolPermission>,
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/config/upsert",
|
path = "/config/upsert",
|
||||||
@@ -389,6 +401,34 @@ pub async fn init_config(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/config/permissions",
|
||||||
|
request_body = UpsertPermissionsQuery,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Permission update completed", body = String),
|
||||||
|
(status = 400, description = "Invalid request"),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn upsert_permissions(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(query): Json<UpsertPermissionsQuery>,
|
||||||
|
) -> Result<Json<String>, StatusCode> {
|
||||||
|
verify_secret_key(&headers, &state)?;
|
||||||
|
|
||||||
|
let mut permission_manager = PermissionManager::default();
|
||||||
|
// Iterate over each tool permission and update permissions
|
||||||
|
for tool_permission in &query.tool_permissions {
|
||||||
|
permission_manager.update_user_permission(
|
||||||
|
&tool_permission.tool_name,
|
||||||
|
tool_permission.permission.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json("Permissions updated successfully".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes(state: AppState) -> Router {
|
pub fn routes(state: AppState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/config", get(read_all_config))
|
.route("/config", get(read_all_config))
|
||||||
@@ -400,5 +440,6 @@ pub fn routes(state: AppState) -> Router {
|
|||||||
.route("/config/extensions/:name", delete(remove_extension))
|
.route("/config/extensions/:name", delete(remove_extension))
|
||||||
.route("/config/providers", get(providers))
|
.route("/config/providers", get(providers))
|
||||||
.route("/config/init", post(init_config))
|
.route("/config/init", post(init_config))
|
||||||
|
.route("/config/permissions", post(upsert_permissions))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ async fn ask_handler(
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ToolConfirmationRequest {
|
struct ToolConfirmationRequest {
|
||||||
id: String,
|
id: String,
|
||||||
confirmed: bool,
|
action: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn confirm_handler(
|
async fn confirm_handler(
|
||||||
@@ -389,11 +389,14 @@ async fn confirm_handler(
|
|||||||
let agent = state.agent.clone();
|
let agent = state.agent.clone();
|
||||||
let agent = agent.read().await;
|
let agent = agent.read().await;
|
||||||
let agent = agent.as_ref().ok_or(StatusCode::NOT_FOUND)?;
|
let agent = agent.as_ref().ok_or(StatusCode::NOT_FOUND)?;
|
||||||
let permission = if request.confirmed {
|
|
||||||
Permission::AllowOnce
|
let permission = match request.action.as_str() {
|
||||||
} else {
|
"always_allow" => Permission::AlwaysAllow,
|
||||||
Permission::DenyOnce
|
"allow_once" => Permission::AllowOnce,
|
||||||
|
"deny" => Permission::DenyOnce,
|
||||||
|
_ => Permission::DenyOnce,
|
||||||
};
|
};
|
||||||
|
|
||||||
agent
|
agent
|
||||||
.handle_confirmation(
|
.handle_confirmation(
|
||||||
request.id.clone(),
|
request.id.clone(),
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ pub enum ExtensionError {
|
|||||||
Transport(#[from] mcp_client::transport::Error),
|
Transport(#[from] mcp_client::transport::Error),
|
||||||
#[error("Environment variable `{0}` is not allowed to be overridden.")]
|
#[error("Environment variable `{0}` is not allowed to be overridden.")]
|
||||||
InvalidEnvVar(String),
|
InvalidEnvVar(String),
|
||||||
|
#[error("Join error occurred during task execution: {0}")]
|
||||||
|
TaskJoinError(#[from] tokio::task::JoinError),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type ExtensionResult<T> = Result<T, ExtensionError>;
|
pub type ExtensionResult<T> = Result<T, ExtensionError>;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chrono::{DateTime, TimeZone, Utc};
|
use chrono::{DateTime, TimeZone, Utc};
|
||||||
|
use futures::future;
|
||||||
use futures::stream::{FuturesUnordered, StreamExt};
|
use futures::stream::{FuturesUnordered, StreamExt};
|
||||||
use mcp_client::McpService;
|
use mcp_client::McpService;
|
||||||
use mcp_core::protocol::GetPromptResult;
|
use mcp_core::protocol::GetPromptResult;
|
||||||
@@ -8,6 +9,7 @@ use std::sync::Arc;
|
|||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
use tokio::task;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use super::extension::{ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult, ToolInfo};
|
use super::extension::{ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult, ToolInfo};
|
||||||
@@ -230,10 +232,12 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
/// Get all tools from all clients with proper prefixing
|
/// Get all tools from all clients with proper prefixing
|
||||||
pub async fn get_prefixed_tools(&self) -> ExtensionResult<Vec<Tool>> {
|
pub async fn get_prefixed_tools(&self) -> ExtensionResult<Vec<Tool>> {
|
||||||
let mut tools = Vec::new();
|
let client_futures = self.clients.iter().map(|(name, client)| {
|
||||||
|
let name = name.clone();
|
||||||
|
let client = client.clone();
|
||||||
|
|
||||||
// Add tools from MCP extensions with prefixing
|
task::spawn(async move {
|
||||||
for (name, client) in &self.clients {
|
let mut tools = Vec::new();
|
||||||
let client_guard = client.lock().await;
|
let client_guard = client.lock().await;
|
||||||
let mut client_tools = client_guard.list_tools(None).await?;
|
let mut client_tools = client_guard.list_tools(None).await?;
|
||||||
|
|
||||||
@@ -247,13 +251,29 @@ impl ExtensionManager {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// exit loop when there are no more pages
|
// Exit loop when there are no more pages
|
||||||
if client_tools.next_cursor.is_none() {
|
if client_tools.next_cursor.is_none() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
client_tools = client_guard.list_tools(client_tools.next_cursor).await?;
|
client_tools = client_guard.list_tools(client_tools.next_cursor).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok::<Vec<Tool>, ExtensionError>(tools)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect all results concurrently
|
||||||
|
let results = future::join_all(client_futures).await;
|
||||||
|
|
||||||
|
// Aggregate tools and handle errors
|
||||||
|
let mut tools = Vec::new();
|
||||||
|
for result in results {
|
||||||
|
match result {
|
||||||
|
Ok(Ok(client_tools)) => tools.extend(client_tools),
|
||||||
|
Ok(Err(err)) => return Err(err),
|
||||||
|
Err(join_err) => return Err(ExtensionError::from(join_err)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(tools)
|
Ok(tools)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/Tool"
|
"$ref": "#/components/schemas/ToolInfo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,6 +196,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/config/permissions": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"super::routes::config_management"
|
||||||
|
],
|
||||||
|
"operationId": "upsert_permissions",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UpsertPermissionsQuery"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Permission update completed",
|
||||||
|
"content": {
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/config/providers": {
|
"/config/providers": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -783,6 +816,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ToolPermission": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"tool_name",
|
||||||
|
"permission"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"permission": {
|
||||||
|
"$ref": "#/components/schemas/PermissionLevel"
|
||||||
|
},
|
||||||
|
"tool_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unique identifier and name of the tool, format <extension_name>__<tool_name>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"UpsertConfigQuery": {
|
"UpsertConfigQuery": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@@ -799,6 +848,20 @@
|
|||||||
},
|
},
|
||||||
"value": {}
|
"value": {}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"UpsertPermissionsQuery": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"tool_permissions"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"tool_permissions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ToolPermission"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
943
ui/desktop/package-lock.json
generated
943
ui/desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -82,12 +82,12 @@
|
|||||||
"@hey-api/client-fetch": "^0.8.1",
|
"@hey-api/client-fetch": "^0.8.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.2",
|
"@radix-ui/react-accordion": "^1.2.2",
|
||||||
"@radix-ui/react-avatar": "^1.1.1",
|
"@radix-ui/react-avatar": "^1.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.7",
|
||||||
"@radix-ui/react-icons": "^1.3.1",
|
"@radix-ui/react-icons": "^1.3.1",
|
||||||
"@radix-ui/react-popover": "^1.1.6",
|
"@radix-ui/react-popover": "^1.1.6",
|
||||||
"@radix-ui/react-radio-group": "^1.2.3",
|
"@radix-ui/react-radio-group": "^1.2.3",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||||
"@radix-ui/react-select": "^2.1.5",
|
"@radix-ui/react-select": "^2.1.7",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
"@radix-ui/themes": "^3.1.5",
|
"@radix-ui/themes": "^3.1.5",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
|
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
|
||||||
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse } from './types.gen';
|
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse } from './types.gen';
|
||||||
import { client as _heyApiClient } from './client.gen';
|
import { client as _heyApiClient } from './client.gen';
|
||||||
|
|
||||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
|
||||||
@@ -64,6 +64,17 @@ export const initConfig = <ThrowOnError extends boolean = false>(options?: Optio
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const upsertPermissions = <ThrowOnError extends boolean = false>(options: Options<UpsertPermissionsData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? _heyApiClient).post<UpsertPermissionsResponse, unknown, ThrowOnError>({
|
||||||
|
url: '/config/permissions',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const providers = <ThrowOnError extends boolean = false>(options?: Options<ProvidersData, ThrowOnError>) => {
|
export const providers = <ThrowOnError extends boolean = false>(options?: Options<ProvidersData, ThrowOnError>) => {
|
||||||
return (options?.client ?? _heyApiClient).get<ProvidersResponse2, unknown, ThrowOnError>({
|
return (options?.client ?? _heyApiClient).get<ProvidersResponse2, unknown, ThrowOnError>({
|
||||||
url: '/config/providers',
|
url: '/config/providers',
|
||||||
|
|||||||
@@ -235,12 +235,24 @@ export type ToolInfo = {
|
|||||||
permission?: PermissionLevel | null;
|
permission?: PermissionLevel | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ToolPermission = {
|
||||||
|
permission: PermissionLevel;
|
||||||
|
/**
|
||||||
|
* Unique identifier and name of the tool, format <extension_name>__<tool_name>
|
||||||
|
*/
|
||||||
|
tool_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type UpsertConfigQuery = {
|
export type UpsertConfigQuery = {
|
||||||
is_secret: boolean;
|
is_secret: boolean;
|
||||||
key: string;
|
key: string;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpsertPermissionsQuery = {
|
||||||
|
tool_permissions: Array<ToolPermission>;
|
||||||
|
};
|
||||||
|
|
||||||
export type GetToolsData = {
|
export type GetToolsData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -272,7 +284,7 @@ export type GetToolsResponses = {
|
|||||||
/**
|
/**
|
||||||
* Tools retrieved successfully
|
* Tools retrieved successfully
|
||||||
*/
|
*/
|
||||||
200: Array<Tool>;
|
200: Array<ToolInfo>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetToolsResponse = GetToolsResponses[keyof GetToolsResponses];
|
export type GetToolsResponse = GetToolsResponses[keyof GetToolsResponses];
|
||||||
@@ -399,6 +411,29 @@ export type InitConfigResponses = {
|
|||||||
|
|
||||||
export type InitConfigResponse = InitConfigResponses[keyof InitConfigResponses];
|
export type InitConfigResponse = InitConfigResponses[keyof InitConfigResponses];
|
||||||
|
|
||||||
|
export type UpsertPermissionsData = {
|
||||||
|
body: UpsertPermissionsQuery;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/config/permissions';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpsertPermissionsErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid request
|
||||||
|
*/
|
||||||
|
400: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpsertPermissionsResponses = {
|
||||||
|
/**
|
||||||
|
* Permission update completed
|
||||||
|
*/
|
||||||
|
200: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpsertPermissionsResponse = UpsertPermissionsResponses[keyof UpsertPermissionsResponses];
|
||||||
|
|
||||||
export type ProvidersData = {
|
export type ProvidersData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -1,22 +1,53 @@
|
|||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ConfirmToolRequest } from '../utils/toolConfirm';
|
import { ConfirmToolRequest } from '../utils/toolConfirm';
|
||||||
import { snakeToTitleCase } from '../utils';
|
import { snakeToTitleCase } from '../utils';
|
||||||
|
import PermissionModal from './settings_v2/permission/PermissionModal';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
const ALWAYS_ALLOW = 'always_allow';
|
||||||
|
const ALLOW_ONCE = 'allow_once';
|
||||||
|
const DENY = 'deny';
|
||||||
|
|
||||||
|
interface ToolConfirmationProps {
|
||||||
|
isCancelledMessage: boolean;
|
||||||
|
isClicked: boolean;
|
||||||
|
toolConfirmationId: string;
|
||||||
|
toolName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ToolConfirmation({
|
export default function ToolConfirmation({
|
||||||
isCancelledMessage,
|
isCancelledMessage,
|
||||||
isClicked,
|
isClicked,
|
||||||
toolConfirmationId,
|
toolConfirmationId,
|
||||||
toolName,
|
toolName,
|
||||||
}) {
|
}: ToolConfirmationProps) {
|
||||||
const [clicked, setClicked] = useState(isClicked);
|
const [clicked, setClicked] = useState(isClicked);
|
||||||
const [status, setStatus] = useState('unknown');
|
const [status, setStatus] = useState('unknown');
|
||||||
|
const [actionDisplay, setActionDisplay] = useState('');
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
const handleButtonClick = (confirmed) => {
|
const handleButtonClick = (action: string) => {
|
||||||
setClicked(true);
|
setClicked(true);
|
||||||
setStatus(confirmed ? 'approved' : 'denied');
|
setStatus(action);
|
||||||
ConfirmToolRequest(toolConfirmationId, confirmed);
|
if (action === ALWAYS_ALLOW) {
|
||||||
|
setActionDisplay('always allowed');
|
||||||
|
} else if (action === ALLOW_ONCE) {
|
||||||
|
setActionDisplay('allowed once');
|
||||||
|
} else {
|
||||||
|
setActionDisplay('denied');
|
||||||
|
}
|
||||||
|
ConfirmToolRequest(toolConfirmationId, action);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleModalClose = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
function getExtensionName(toolName: string): string {
|
||||||
|
const parts = toolName.split('__');
|
||||||
|
return parts.length > 1 ? parts[0] : '';
|
||||||
|
}
|
||||||
|
|
||||||
return isCancelledMessage ? (
|
return isCancelledMessage ? (
|
||||||
<div className="goose-message-content bg-bgSubtle rounded-2xl px-4 py-2 text-textStandard">
|
<div className="goose-message-content bg-bgSubtle rounded-2xl px-4 py-2 text-textStandard">
|
||||||
Tool call confirmation is cancelled.
|
Tool call confirmation is cancelled.
|
||||||
@@ -27,9 +58,9 @@ export default function ToolConfirmation({
|
|||||||
Goose would like to call the above tool. Allow?
|
Goose would like to call the above tool. Allow?
|
||||||
</div>
|
</div>
|
||||||
{clicked ? (
|
{clicked ? (
|
||||||
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-4 pb-2 flex gap-4 mt-1">
|
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{status === 'approved' && (
|
{status === 'always_allow' && (
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5 text-gray-500"
|
className="w-5 h-5 text-gray-500"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -41,7 +72,19 @@ export default function ToolConfirmation({
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{status === 'denied' && (
|
{status === 'allow_once' && (
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-gray-500"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{status === 'deny' && (
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5 text-gray-500"
|
className="w-5 h-5 text-gray-500"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -56,30 +99,48 @@ export default function ToolConfirmation({
|
|||||||
<span className="ml-2 text-textStandard">
|
<span className="ml-2 text-textStandard">
|
||||||
{isClicked
|
{isClicked
|
||||||
? 'Tool confirmation is not available'
|
? 'Tool confirmation is not available'
|
||||||
: `${snakeToTitleCase(toolName.substring(toolName.lastIndexOf('__') + 2))} is ${status}`}
|
: `${snakeToTitleCase(toolName.substring(toolName.lastIndexOf('__') + 2))} is ${actionDisplay}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center cursor-pointer" onClick={() => setIsModalOpen(true)}>
|
||||||
|
<span className="mr-1 text-textStandard">Change</span>
|
||||||
|
<ChevronRight className="w-4 h-4 ml-1 text-iconStandard" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-4 pb-2 flex gap-4 mt-1">
|
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex gap-2 items-center">
|
||||||
<button
|
<button
|
||||||
className={
|
className={
|
||||||
'bg-black text-white dark:bg-white dark:text-black rounded-full px-6 py-2 transition'
|
'bg-black text-white dark:bg-white dark:text-black rounded-full px-6 py-2 transition'
|
||||||
}
|
}
|
||||||
onClick={() => handleButtonClick(true)}
|
onClick={() => handleButtonClick(ALWAYS_ALLOW)}
|
||||||
>
|
>
|
||||||
Allow tool
|
Always Allow
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
'bg-bgProminent text-white dark:text-white rounded-full px-6 py-2 transition'
|
||||||
|
}
|
||||||
|
onClick={() => handleButtonClick(ALLOW_ONCE)}
|
||||||
|
>
|
||||||
|
Allow Once
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={
|
className={
|
||||||
'bg-white text-black dark:bg-black dark:text-white border border-gray-300 dark:border-gray-700 rounded-full px-6 py-2 transition'
|
'bg-white text-black dark:bg-black dark:text-white border border-gray-300 dark:border-gray-700 rounded-full px-6 py-2 transition'
|
||||||
}
|
}
|
||||||
onClick={() => handleButtonClick(false)}
|
onClick={() => handleButtonClick(DENY)}
|
||||||
>
|
>
|
||||||
Deny
|
Deny
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modal for updating tool permission */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<PermissionModal onClose={handleModalClose} extensionName={getExtensionName(toolName)} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button } from '../../ui/button';
|
||||||
|
import { ChevronDownIcon, SlidersHorizontal } from 'lucide-react';
|
||||||
|
import { getTools, PermissionLevel, ToolInfo, upsertPermissions } from '../../../api';
|
||||||
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||||
|
|
||||||
|
function getFirstSentence(text: string): string {
|
||||||
|
const match = text.match(/^([^.?!]+[.?!])/);
|
||||||
|
return match ? match[0] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionModalProps {
|
||||||
|
extensionName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PermissionModal({ extensionName, onClose }: PermissionModalProps) {
|
||||||
|
const permissionOptions = [
|
||||||
|
{ value: 'always_allow', label: 'Always allow' },
|
||||||
|
{ value: 'ask_before', label: 'Ask before' },
|
||||||
|
{ value: 'never_allow', label: 'Never allow' },
|
||||||
|
] as { value: PermissionLevel; label: string }[];
|
||||||
|
|
||||||
|
const [tools, setTools] = useState<ToolInfo[]>([]);
|
||||||
|
const [updatedPermissions, setUpdatedPermissions] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTools = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getTools({ query: { extension_name: extensionName } });
|
||||||
|
if (response.error) {
|
||||||
|
console.error('Failed to get tools');
|
||||||
|
} else {
|
||||||
|
setTools(response.data || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching tools:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTools();
|
||||||
|
}, [extensionName]);
|
||||||
|
|
||||||
|
const handleSettingChange = (toolName: string, newPermission: PermissionLevel) => {
|
||||||
|
setUpdatedPermissions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[toolName]: newPermission,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
tool_permissions: Object.entries(updatedPermissions).map(([toolName, permission]) => ({
|
||||||
|
tool_name: toolName,
|
||||||
|
permission: permission as PermissionLevel,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (payload.tool_permissions.length === 0) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await upsertPermissions({
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
if (response.error) {
|
||||||
|
console.error('Failed to save permissions:', response.error);
|
||||||
|
} else {
|
||||||
|
console.log('Permissions updated successfully');
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving permissions:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const footerContent = (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="w-full h-[60px] rounded-none border-b border-borderSubtle bg-transparent hover:bg-bgSubtle text-textProminent font-medium text-md"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full h-[60px] rounded-none hover:bg-bgSubtle text-textSubtle hover:text-textStandard text-md font-regular"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={true} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 bg-black/30 dark:bg-white/20 transition-colors animate-[fadein_200ms_ease-in_forwards]" />
|
||||||
|
<Dialog.Content className="fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] w-[500px] max-w-[90vw] bg-bgApp rounded-xl shadow-xl max-h-[90vh] flex flex-col">
|
||||||
|
<div className="p-6">
|
||||||
|
<Dialog.Title className="DialogTitle flex justify-start items-center mb-6">
|
||||||
|
<SlidersHorizontal className="text-iconStandard" size={24} />
|
||||||
|
<p className="ml-2 text-2xl font-semibold text-textStandard">{extensionName}</p>
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description className="DialogDescription"></Dialog.Description>
|
||||||
|
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
{tools.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
{/* Loading spinner */}
|
||||||
|
<svg
|
||||||
|
className="animate-spin h-8 w-8 text-grey-50 dark:text-white"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8v8H4z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<div
|
||||||
|
key={tool.name}
|
||||||
|
className="mb-4 flex items-center justify-between grid grid-cols-12"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col col-span-8">
|
||||||
|
<label className="block text-sm font-medium text-textStandard">
|
||||||
|
{tool.name}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-gray-500 mb-2">
|
||||||
|
{getFirstSentence(tool.description)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger className="flex col-span-4 items-center justify-center bg-bgSubtle text-textStandard rounded-full px-3 py-2">
|
||||||
|
<span>
|
||||||
|
{permissionOptions.find(
|
||||||
|
(option) =>
|
||||||
|
option.value === (updatedPermissions[tool.name] || tool.permission)
|
||||||
|
)?.label || 'Ask Before'}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon className="ml-2 h-4 w-4" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content className="bg-white dark:bg-bgProminent rounded-lg shadow-md">
|
||||||
|
{permissionOptions.map((option) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={option.value}
|
||||||
|
className="px-8 py-2 rounded-lg hover:cursor-pointer hover:bg-bgSubtle text-textStandard"
|
||||||
|
onSelect={() =>
|
||||||
|
handleSettingChange(tool.name, option.value as PermissionLevel)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footerContent && (
|
||||||
|
<div className="border-t border-borderSubtle bg-bgApp w-full rounded-b-xl overflow-hidden">
|
||||||
|
{footerContent}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getApiUrl, getSecretKey } from '../config';
|
import { getApiUrl, getSecretKey } from '../config';
|
||||||
|
|
||||||
export async function ConfirmToolRequest(requesyId: string, confirmed: boolean) {
|
export async function ConfirmToolRequest(requesyId: string, action: string) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(getApiUrl('/confirm'), {
|
const response = await fetch(getApiUrl('/confirm'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -10,7 +10,7 @@ export async function ConfirmToolRequest(requesyId: string, confirmed: boolean)
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: requesyId,
|
id: requesyId,
|
||||||
confirmed,
|
action,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user