mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-24 01:24:28 +01:00
fix: use env keys (#2258)
Co-authored-by: Zaki Ali <zaki@squareup.com> Co-authored-by: Kalvin C <kalvinnchau@users.noreply.github.com>
This commit is contained in:
@@ -583,6 +583,9 @@ pub fn configure_extensions_dialog() -> Result<(), Box<dyn Error>> {
|
|||||||
cliclack::confirm("Would you like to add environment variables?").interact()?;
|
cliclack::confirm("Would you like to add environment variables?").interact()?;
|
||||||
|
|
||||||
let mut envs = HashMap::new();
|
let mut envs = HashMap::new();
|
||||||
|
let mut env_keys = Vec::new();
|
||||||
|
let config = Config::global();
|
||||||
|
|
||||||
if add_env {
|
if add_env {
|
||||||
loop {
|
loop {
|
||||||
let key: String = cliclack::input("Environment variable name:")
|
let key: String = cliclack::input("Environment variable name:")
|
||||||
@@ -593,7 +596,18 @@ pub fn configure_extensions_dialog() -> Result<(), Box<dyn Error>> {
|
|||||||
.mask('▪')
|
.mask('▪')
|
||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
||||||
envs.insert(key, value);
|
// Try to store in keychain
|
||||||
|
let keychain_key = key.to_string();
|
||||||
|
match config.set_secret(&keychain_key, Value::String(value.clone())) {
|
||||||
|
Ok(_) => {
|
||||||
|
// Successfully stored in keychain, add to env_keys
|
||||||
|
env_keys.push(keychain_key);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Failed to store in keychain, store directly in envs
|
||||||
|
envs.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !cliclack::confirm("Add another environment variable?").interact()? {
|
if !cliclack::confirm("Add another environment variable?").interact()? {
|
||||||
break;
|
break;
|
||||||
@@ -608,6 +622,7 @@ pub fn configure_extensions_dialog() -> Result<(), Box<dyn Error>> {
|
|||||||
cmd,
|
cmd,
|
||||||
args,
|
args,
|
||||||
envs: Envs::new(envs),
|
envs: Envs::new(envs),
|
||||||
|
env_keys,
|
||||||
description,
|
description,
|
||||||
timeout: Some(timeout),
|
timeout: Some(timeout),
|
||||||
bundled: None,
|
bundled: None,
|
||||||
@@ -671,6 +686,9 @@ pub fn configure_extensions_dialog() -> Result<(), Box<dyn Error>> {
|
|||||||
cliclack::confirm("Would you like to add environment variables?").interact()?;
|
cliclack::confirm("Would you like to add environment variables?").interact()?;
|
||||||
|
|
||||||
let mut envs = HashMap::new();
|
let mut envs = HashMap::new();
|
||||||
|
let mut env_keys = Vec::new();
|
||||||
|
let config = Config::global();
|
||||||
|
|
||||||
if add_env {
|
if add_env {
|
||||||
loop {
|
loop {
|
||||||
let key: String = cliclack::input("Environment variable name:")
|
let key: String = cliclack::input("Environment variable name:")
|
||||||
@@ -681,7 +699,18 @@ pub fn configure_extensions_dialog() -> Result<(), Box<dyn Error>> {
|
|||||||
.mask('▪')
|
.mask('▪')
|
||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
||||||
envs.insert(key, value);
|
// Try to store in keychain
|
||||||
|
let keychain_key = key.to_string();
|
||||||
|
match config.set_secret(&keychain_key, Value::String(value.clone())) {
|
||||||
|
Ok(_) => {
|
||||||
|
// Successfully stored in keychain, add to env_keys
|
||||||
|
env_keys.push(keychain_key);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Failed to store in keychain, store directly in envs
|
||||||
|
envs.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !cliclack::confirm("Add another environment variable?").interact()? {
|
if !cliclack::confirm("Add another environment variable?").interact()? {
|
||||||
break;
|
break;
|
||||||
@@ -695,6 +724,7 @@ pub fn configure_extensions_dialog() -> Result<(), Box<dyn Error>> {
|
|||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
uri,
|
uri,
|
||||||
envs: Envs::new(envs),
|
envs: Envs::new(envs),
|
||||||
|
env_keys,
|
||||||
description,
|
description,
|
||||||
timeout: Some(timeout),
|
timeout: Some(timeout),
|
||||||
bundled: None,
|
bundled: None,
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ impl Session {
|
|||||||
cmd,
|
cmd,
|
||||||
args: parts.iter().map(|s| s.to_string()).collect(),
|
args: parts.iter().map(|s| s.to_string()).collect(),
|
||||||
envs: Envs::new(envs),
|
envs: Envs::new(envs),
|
||||||
|
env_keys: Vec::new(),
|
||||||
description: Some(goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string()),
|
description: Some(goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string()),
|
||||||
// TODO: should set timeout
|
// TODO: should set timeout
|
||||||
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
|
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
|
||||||
@@ -190,6 +191,7 @@ impl Session {
|
|||||||
name,
|
name,
|
||||||
uri: extension_url,
|
uri: extension_url,
|
||||||
envs: Envs::new(HashMap::new()),
|
envs: Envs::new(HashMap::new()),
|
||||||
|
env_keys: Vec::new(),
|
||||||
description: Some(goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string()),
|
description: Some(goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string()),
|
||||||
// TODO: should set timeout
|
// TODO: should set timeout
|
||||||
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
|
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use utoipa::OpenApi;
|
|||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
paths(
|
||||||
|
super::routes::config_management::backup_config,
|
||||||
super::routes::config_management::init_config,
|
super::routes::config_management::init_config,
|
||||||
super::routes::config_management::upsert_config,
|
super::routes::config_management::upsert_config,
|
||||||
super::routes::config_management::remove_config,
|
super::routes::config_management::remove_config,
|
||||||
|
|||||||
@@ -292,7 +292,9 @@ pub async fn read_all_config(
|
|||||||
let config = Config::global();
|
let config = Config::global();
|
||||||
|
|
||||||
// Load values from config file
|
// Load values from config file
|
||||||
let values = config.load_values().unwrap_or_default();
|
let values = config
|
||||||
|
.load_values()
|
||||||
|
.map_err(|_| StatusCode::UNPROCESSABLE_ENTITY)?;
|
||||||
|
|
||||||
Ok(Json(ConfigResponse { config: values }))
|
Ok(Json(ConfigResponse { config: values }))
|
||||||
}
|
}
|
||||||
@@ -429,6 +431,54 @@ pub async fn upsert_permissions(
|
|||||||
Ok(Json("Permissions updated successfully".to_string()))
|
Ok(Json("Permissions updated successfully".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
pub static APP_STRATEGY: Lazy<AppStrategyArgs> = Lazy::new(|| AppStrategyArgs {
|
||||||
|
top_level_domain: "Block".to_string(),
|
||||||
|
author: "Block".to_string(),
|
||||||
|
app_name: "goose".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/config/backup",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Config file backed up", body = String),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn backup_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<String>, StatusCode> {
|
||||||
|
verify_secret_key(&headers, &state)?;
|
||||||
|
|
||||||
|
let config_dir = choose_app_strategy(APP_STRATEGY.clone())
|
||||||
|
.expect("goose requires a home dir")
|
||||||
|
.config_dir();
|
||||||
|
|
||||||
|
let config_path = config_dir.join("config.yaml");
|
||||||
|
|
||||||
|
if config_path.exists() {
|
||||||
|
let file_name = config_path
|
||||||
|
.file_name()
|
||||||
|
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
// Append ".bak" to the file name
|
||||||
|
let mut backup_name = file_name.to_os_string();
|
||||||
|
backup_name.push(".bak");
|
||||||
|
|
||||||
|
// Construct the new path with the same parent directory
|
||||||
|
let backup = config_path.with_file_name(backup_name);
|
||||||
|
match std::fs::rename(&config_path, &backup) {
|
||||||
|
Ok(_) => Ok(Json(format!("Moved {:?} to {:?}", config_path, backup))),
|
||||||
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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))
|
||||||
@@ -440,6 +490,7 @@ 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/backup", post(backup_config))
|
||||||
.route("/config/permissions", post(upsert_permissions))
|
.route("/config/permissions", post(upsert_permissions))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use axum::{extract::State, routing::post, Json, Router};
|
use axum::{extract::State, routing::post, Json, Router};
|
||||||
use goose::{
|
use goose::agents::{extension::Envs, ExtensionConfig};
|
||||||
agents::{extension::Envs, ExtensionConfig},
|
|
||||||
config::Config,
|
|
||||||
};
|
|
||||||
use http::{HeaderMap, StatusCode};
|
use http::{HeaderMap, StatusCode};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing;
|
use tracing;
|
||||||
@@ -24,6 +20,9 @@ enum ExtensionConfigRequest {
|
|||||||
name: String,
|
name: String,
|
||||||
/// The URI endpoint for the SSE extension.
|
/// The URI endpoint for the SSE extension.
|
||||||
uri: String,
|
uri: String,
|
||||||
|
#[serde(default)]
|
||||||
|
/// Map of environment variable key to values.
|
||||||
|
envs: Envs,
|
||||||
/// List of environment variable keys. The server will fetch their values from the keyring.
|
/// List of environment variable keys. The server will fetch their values from the keyring.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
env_keys: Vec<String>,
|
env_keys: Vec<String>,
|
||||||
@@ -39,6 +38,9 @@ enum ExtensionConfigRequest {
|
|||||||
/// Arguments for the command.
|
/// Arguments for the command.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
/// Map of environment variable key to values.
|
||||||
|
envs: Envs,
|
||||||
/// List of environment variable keys. The server will fetch their values from the keyring.
|
/// List of environment variable keys. The server will fetch their values from the keyring.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
env_keys: Vec<String>,
|
env_keys: Vec<String>,
|
||||||
@@ -162,55 +164,28 @@ async fn add_extension(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the configuration
|
|
||||||
let config = Config::global();
|
|
||||||
|
|
||||||
// Initialize a vector to collect any missing keys.
|
|
||||||
let mut missing_keys = Vec::new();
|
|
||||||
|
|
||||||
// Construct ExtensionConfig with Envs populated from keyring based on provided env_keys.
|
// Construct ExtensionConfig with Envs populated from keyring based on provided env_keys.
|
||||||
let extension_config: ExtensionConfig = match request {
|
let extension_config: ExtensionConfig = match request {
|
||||||
ExtensionConfigRequest::Sse {
|
ExtensionConfigRequest::Sse {
|
||||||
name,
|
name,
|
||||||
uri,
|
uri,
|
||||||
|
envs,
|
||||||
env_keys,
|
env_keys,
|
||||||
timeout,
|
timeout,
|
||||||
} => {
|
} => ExtensionConfig::Sse {
|
||||||
let mut env_map = HashMap::new();
|
name,
|
||||||
for key in env_keys {
|
uri,
|
||||||
match config.get_secret(&key) {
|
envs,
|
||||||
Ok(value) => {
|
env_keys,
|
||||||
env_map.insert(key, value);
|
description: None,
|
||||||
}
|
timeout,
|
||||||
Err(_) => {
|
bundled: None,
|
||||||
missing_keys.push(key);
|
},
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !missing_keys.is_empty() {
|
|
||||||
return Ok(Json(ExtensionResponse {
|
|
||||||
error: true,
|
|
||||||
message: Some(format!(
|
|
||||||
"Missing secrets for keys: {}",
|
|
||||||
missing_keys.join(", ")
|
|
||||||
)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
ExtensionConfig::Sse {
|
|
||||||
name,
|
|
||||||
uri,
|
|
||||||
envs: Envs::new(env_map),
|
|
||||||
description: None,
|
|
||||||
timeout,
|
|
||||||
bundled: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ExtensionConfigRequest::Stdio {
|
ExtensionConfigRequest::Stdio {
|
||||||
name,
|
name,
|
||||||
cmd,
|
cmd,
|
||||||
args,
|
args,
|
||||||
|
envs,
|
||||||
env_keys,
|
env_keys,
|
||||||
timeout,
|
timeout,
|
||||||
} => {
|
} => {
|
||||||
@@ -226,34 +201,13 @@ async fn add_extension(
|
|||||||
// }));
|
// }));
|
||||||
// }
|
// }
|
||||||
|
|
||||||
let mut env_map = HashMap::new();
|
|
||||||
for key in env_keys {
|
|
||||||
match config.get_secret(&key) {
|
|
||||||
Ok(value) => {
|
|
||||||
env_map.insert(key, value);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
missing_keys.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !missing_keys.is_empty() {
|
|
||||||
return Ok(Json(ExtensionResponse {
|
|
||||||
error: true,
|
|
||||||
message: Some(format!(
|
|
||||||
"Missing secrets for keys: {}",
|
|
||||||
missing_keys.join(", ")
|
|
||||||
)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
ExtensionConfig::Stdio {
|
ExtensionConfig::Stdio {
|
||||||
name,
|
name,
|
||||||
cmd,
|
cmd,
|
||||||
args,
|
args,
|
||||||
description: None,
|
description: None,
|
||||||
envs: Envs::new(env_map),
|
envs,
|
||||||
|
env_keys,
|
||||||
timeout,
|
timeout,
|
||||||
bundled: None,
|
bundled: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("Error during extension setup: {0}")]
|
||||||
|
SetupError(String),
|
||||||
#[error("Join error occurred during task execution: {0}")]
|
#[error("Join error occurred during task execution: {0}")]
|
||||||
TaskJoinError(#[from] tokio::task::JoinError),
|
TaskJoinError(#[from] tokio::task::JoinError),
|
||||||
}
|
}
|
||||||
@@ -128,6 +130,8 @@ pub enum ExtensionConfig {
|
|||||||
uri: String,
|
uri: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
envs: Envs,
|
envs: Envs,
|
||||||
|
#[serde(default)]
|
||||||
|
env_keys: Vec<String>,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
// NOTE: set timeout to be optional for compatibility.
|
// NOTE: set timeout to be optional for compatibility.
|
||||||
// However, new configurations should include this field.
|
// However, new configurations should include this field.
|
||||||
@@ -145,6 +149,8 @@ pub enum ExtensionConfig {
|
|||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
envs: Envs,
|
envs: Envs,
|
||||||
|
#[serde(default)]
|
||||||
|
env_keys: Vec<String>,
|
||||||
timeout: Option<u64>,
|
timeout: Option<u64>,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
/// Whether this extension is bundled with Goose
|
/// Whether this extension is bundled with Goose
|
||||||
@@ -194,6 +200,7 @@ impl ExtensionConfig {
|
|||||||
name: name.into(),
|
name: name.into(),
|
||||||
uri: uri.into(),
|
uri: uri.into(),
|
||||||
envs: Envs::default(),
|
envs: Envs::default(),
|
||||||
|
env_keys: Vec::new(),
|
||||||
description: Some(description.into()),
|
description: Some(description.into()),
|
||||||
timeout: Some(timeout.into()),
|
timeout: Some(timeout.into()),
|
||||||
bundled: None,
|
bundled: None,
|
||||||
@@ -211,6 +218,7 @@ impl ExtensionConfig {
|
|||||||
cmd: cmd.into(),
|
cmd: cmd.into(),
|
||||||
args: vec![],
|
args: vec![],
|
||||||
envs: Envs::default(),
|
envs: Envs::default(),
|
||||||
|
env_keys: Vec::new(),
|
||||||
description: Some(description.into()),
|
description: Some(description.into()),
|
||||||
timeout: Some(timeout.into()),
|
timeout: Some(timeout.into()),
|
||||||
bundled: None,
|
bundled: None,
|
||||||
@@ -227,6 +235,7 @@ impl ExtensionConfig {
|
|||||||
name,
|
name,
|
||||||
cmd,
|
cmd,
|
||||||
envs,
|
envs,
|
||||||
|
env_keys,
|
||||||
timeout,
|
timeout,
|
||||||
description,
|
description,
|
||||||
bundled,
|
bundled,
|
||||||
@@ -235,6 +244,7 @@ impl ExtensionConfig {
|
|||||||
name,
|
name,
|
||||||
cmd,
|
cmd,
|
||||||
envs,
|
envs,
|
||||||
|
env_keys,
|
||||||
args: args.into_iter().map(Into::into).collect(),
|
args: args.into_iter().map(Into::into).collect(),
|
||||||
description,
|
description,
|
||||||
timeout,
|
timeout,
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ use std::sync::LazyLock;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio::task;
|
use tokio::task;
|
||||||
use tracing::debug;
|
use tracing::{debug, error, warn};
|
||||||
|
|
||||||
use super::extension::{ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult, ToolInfo};
|
use super::extension::{ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult, ToolInfo};
|
||||||
use crate::config::ExtensionConfigManager;
|
use crate::agents::extension::Envs;
|
||||||
|
use crate::config::{Config, ExtensionConfigManager};
|
||||||
use crate::prompt_template;
|
use crate::prompt_template;
|
||||||
use mcp_client::client::{ClientCapabilities, ClientInfo, McpClient, McpClientTrait};
|
use mcp_client::client::{ClientCapabilities, ClientInfo, McpClient, McpClientTrait};
|
||||||
use mcp_client::transport::{SseTransport, StdioTransport, Transport};
|
use mcp_client::transport::{SseTransport, StdioTransport, Transport};
|
||||||
@@ -113,11 +114,74 @@ impl ExtensionManager {
|
|||||||
// TODO IMPORTANT need to ensure this times out if the extension command is broken!
|
// TODO IMPORTANT need to ensure this times out if the extension command is broken!
|
||||||
pub async fn add_extension(&mut self, config: ExtensionConfig) -> ExtensionResult<()> {
|
pub async fn add_extension(&mut self, config: ExtensionConfig) -> ExtensionResult<()> {
|
||||||
let sanitized_name = normalize(config.key().to_string());
|
let sanitized_name = normalize(config.key().to_string());
|
||||||
|
|
||||||
|
/// Helper function to merge environment variables from direct envs and keychain-stored env_keys
|
||||||
|
async fn merge_environments(
|
||||||
|
envs: &Envs,
|
||||||
|
env_keys: &[String],
|
||||||
|
ext_name: &str,
|
||||||
|
) -> Result<HashMap<String, String>, ExtensionError> {
|
||||||
|
let mut all_envs = envs.get_env();
|
||||||
|
let config_instance = Config::global();
|
||||||
|
|
||||||
|
for key in env_keys {
|
||||||
|
// If the Envs payload already contains the key, prefer that value
|
||||||
|
// over looking into the keychain/secret store
|
||||||
|
if all_envs.contains_key(key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match config_instance.get(key, true) {
|
||||||
|
Ok(value) => {
|
||||||
|
if value.is_null() {
|
||||||
|
warn!(
|
||||||
|
key = %key,
|
||||||
|
ext_name = %ext_name,
|
||||||
|
"Secret key not found in config (returned null)."
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get string value
|
||||||
|
if let Some(str_val) = value.as_str() {
|
||||||
|
all_envs.insert(key.clone(), str_val.to_string());
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
key = %key,
|
||||||
|
ext_name = %ext_name,
|
||||||
|
value_type = %value.get("type").and_then(|t| t.as_str()).unwrap_or("unknown"),
|
||||||
|
"Secret value is not a string; skipping."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
key = %key,
|
||||||
|
ext_name = %ext_name,
|
||||||
|
error = %e,
|
||||||
|
"Failed to fetch secret from config."
|
||||||
|
);
|
||||||
|
return Err(ExtensionError::SetupError(format!(
|
||||||
|
"Failed to fetch secret '{}' from config: {}",
|
||||||
|
key, e
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(all_envs)
|
||||||
|
}
|
||||||
|
|
||||||
let mut client: Box<dyn McpClientTrait> = match &config {
|
let mut client: Box<dyn McpClientTrait> = match &config {
|
||||||
ExtensionConfig::Sse {
|
ExtensionConfig::Sse {
|
||||||
uri, envs, timeout, ..
|
uri,
|
||||||
|
envs,
|
||||||
|
env_keys,
|
||||||
|
timeout,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
let transport = SseTransport::new(uri, envs.get_env());
|
let all_envs = merge_environments(envs, env_keys, &sanitized_name).await?;
|
||||||
|
let transport = SseTransport::new(uri, all_envs);
|
||||||
let handle = transport.start().await?;
|
let handle = transport.start().await?;
|
||||||
let service = McpService::with_timeout(
|
let service = McpService::with_timeout(
|
||||||
handle,
|
handle,
|
||||||
@@ -131,10 +195,12 @@ impl ExtensionManager {
|
|||||||
cmd,
|
cmd,
|
||||||
args,
|
args,
|
||||||
envs,
|
envs,
|
||||||
|
env_keys,
|
||||||
timeout,
|
timeout,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let transport = StdioTransport::new(cmd, args.to_vec(), envs.get_env());
|
let all_envs = merge_environments(envs, env_keys, &sanitized_name).await?;
|
||||||
|
let transport = StdioTransport::new(cmd, args.to_vec(), all_envs);
|
||||||
let handle = transport.start().await?;
|
let handle = transport.start().await?;
|
||||||
let service = McpService::with_timeout(
|
let service = McpService::with_timeout(
|
||||||
handle,
|
handle,
|
||||||
@@ -150,7 +216,6 @@ impl ExtensionManager {
|
|||||||
timeout,
|
timeout,
|
||||||
bundled: _,
|
bundled: _,
|
||||||
} => {
|
} => {
|
||||||
// For builtin extensions, we run the current executable with mcp and extension name
|
|
||||||
let cmd = std::env::current_exe()
|
let cmd = std::env::current_exe()
|
||||||
.expect("should find the current executable")
|
.expect("should find the current executable")
|
||||||
.to_str()
|
.to_str()
|
||||||
@@ -185,19 +250,16 @@ impl ExtensionManager {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ExtensionError::Initialization(config.clone(), e))?;
|
.map_err(|e| ExtensionError::Initialization(config.clone(), e))?;
|
||||||
|
|
||||||
// Store instructions if provided
|
|
||||||
if let Some(instructions) = init_result.instructions {
|
if let Some(instructions) = init_result.instructions {
|
||||||
self.instructions
|
self.instructions
|
||||||
.insert(sanitized_name.clone(), instructions);
|
.insert(sanitized_name.clone(), instructions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the server is capable if resources we track it
|
|
||||||
if init_result.capabilities.resources.is_some() {
|
if init_result.capabilities.resources.is_some() {
|
||||||
self.resource_capable_extensions
|
self.resource_capable_extensions
|
||||||
.insert(sanitized_name.clone());
|
.insert(sanitized_name.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the client using the provided name
|
|
||||||
self.clients
|
self.clients
|
||||||
.insert(sanitized_name.clone(), Arc::new(Mutex::new(client)));
|
.insert(sanitized_name.clone(), Arc::new(Mutex::new(client)));
|
||||||
|
|
||||||
|
|||||||
@@ -126,9 +126,7 @@ impl ExtensionConfigManager {
|
|||||||
/// Get all extensions and their configurations
|
/// Get all extensions and their configurations
|
||||||
pub fn get_all() -> Result<Vec<ExtensionEntry>> {
|
pub fn get_all() -> Result<Vec<ExtensionEntry>> {
|
||||||
let config = Config::global();
|
let config = Config::global();
|
||||||
let extensions: HashMap<String, ExtensionEntry> = config
|
let extensions: HashMap<String, ExtensionEntry> = config.get_param("extensions")?;
|
||||||
.get_param("extensions")
|
|
||||||
.unwrap_or_else(|_| HashMap::new());
|
|
||||||
Ok(Vec::from_iter(extensions.values().cloned()))
|
Ok(Vec::from_iter(extensions.values().cloned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/config/backup": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"super::routes::config_management"
|
||||||
|
],
|
||||||
|
"operationId": "backup_config",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Config file backed up",
|
||||||
|
"content": {
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/config/extensions": {
|
"/config/extensions": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -466,6 +489,12 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"env_keys": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
"envs": {
|
"envs": {
|
||||||
"$ref": "#/components/schemas/Envs"
|
"$ref": "#/components/schemas/Envs"
|
||||||
},
|
},
|
||||||
@@ -518,6 +547,12 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"env_keys": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
"envs": {
|
"envs": {
|
||||||
"$ref": "#/components/schemas/Envs"
|
"$ref": "#/components/schemas/Envs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { addExtension as addExtensionDirect, FullExtensionConfig } from './exten
|
|||||||
import 'react-toastify/dist/ReactToastify.css';
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
import { useConfig, MalformedConfigError } from './components/ConfigContext';
|
import { useConfig, MalformedConfigError } from './components/ConfigContext';
|
||||||
import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './components/settings_v2/extensions';
|
import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './components/settings_v2/extensions';
|
||||||
import { initConfig } from './api/sdk.gen';
|
import { backupConfig, initConfig, readAllConfig } from './api/sdk.gen';
|
||||||
import PermissionSettingsView from './components/settings_v2/permission/PermissionSetting';
|
import PermissionSettingsView from './components/settings_v2/permission/PermissionSetting';
|
||||||
|
|
||||||
// Views and their options
|
// Views and their options
|
||||||
@@ -240,7 +240,7 @@ export default function App() {
|
|||||||
console.log('Finished enabling bot config extensions');
|
console.log('Finished enabling bot config extensions');
|
||||||
};
|
};
|
||||||
|
|
||||||
const enableRecipeConfigExtensionsV2 = useCallback(
|
const _enableRecipeConfigExtensionsV2 = useCallback(
|
||||||
async (extensions: FullExtensionConfig[]) => {
|
async (extensions: FullExtensionConfig[]) => {
|
||||||
if (!extensions?.length) {
|
if (!extensions?.length) {
|
||||||
console.log('No extensions to enable from bot config');
|
console.log('No extensions to enable from bot config');
|
||||||
@@ -299,9 +299,25 @@ export default function App() {
|
|||||||
|
|
||||||
const initializeApp = async () => {
|
const initializeApp = async () => {
|
||||||
try {
|
try {
|
||||||
// Initialize config first
|
// checks if there is a config, and if not creates it
|
||||||
await initConfig();
|
await initConfig();
|
||||||
|
|
||||||
|
// now try to read config, if we fail and are migrating backup, then re-init config
|
||||||
|
try {
|
||||||
|
await readAllConfig({ throwOnError: true });
|
||||||
|
} catch (error) {
|
||||||
|
// NOTE: we do this check here and in providerUtils.ts, be sure to clean up both in the future
|
||||||
|
const configVersion = localStorage.getItem('configVersion');
|
||||||
|
const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3;
|
||||||
|
if (shouldMigrateExtensions) {
|
||||||
|
await backupConfig({ throwOnError: true });
|
||||||
|
await initConfig();
|
||||||
|
} else {
|
||||||
|
// if we've migrated throw this back up
|
||||||
|
throw new Error('Unable to read config file, it may be malformed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// note: if in a non recipe session, recipeConfig is undefined, otherwise null if error
|
// note: if in a non recipe session, recipeConfig is undefined, otherwise null if error
|
||||||
if (recipeConfig === null) {
|
if (recipeConfig === null) {
|
||||||
setFatalError('Cannot read recipe config. Please check the deeplink and try again.');
|
setFatalError('Cannot read recipe config. Please check the deeplink and try again.');
|
||||||
@@ -309,10 +325,10 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle bot config extensions first
|
// Handle bot config extensions first
|
||||||
if (recipeConfig?.extensions?.length > 0 && viewType != 'recipeEditor') {
|
// if (recipeConfig?.extensions?.length > 0 && viewType != 'recipeEditor') {
|
||||||
console.log('Found extensions in bot config:', recipeConfig.extensions);
|
// console.log('Found extensions in bot config:', recipeConfig.extensions);
|
||||||
await enableRecipeConfigExtensionsV2(recipeConfig.extensions);
|
// await enableRecipeConfigExtensionsV2(recipeConfig.extensions);
|
||||||
}
|
// }
|
||||||
|
|
||||||
const config = window.electron.getConfig();
|
const config = window.electron.getConfig();
|
||||||
|
|
||||||
|
|||||||
@@ -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, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData } from './types.gen';
|
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData } 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> & {
|
||||||
@@ -32,6 +32,13 @@ export const readAllConfig = <ThrowOnError extends boolean = false>(options?: Op
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const backupConfig = <ThrowOnError extends boolean = false>(options?: Options<BackupConfigData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? _heyApiClient).post<BackupConfigResponse, unknown, ThrowOnError>({
|
||||||
|
url: '/config/backup',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const getExtensions = <ThrowOnError extends boolean = false>(options?: Options<GetExtensionsData, ThrowOnError>) => {
|
export const getExtensions = <ThrowOnError extends boolean = false>(options?: Options<GetExtensionsData, ThrowOnError>) => {
|
||||||
return (options?.client ?? _heyApiClient).get<GetExtensionsResponse, unknown, ThrowOnError>({
|
return (options?.client ?? _heyApiClient).get<GetExtensionsResponse, unknown, ThrowOnError>({
|
||||||
url: '/config/extensions',
|
url: '/config/extensions',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type ExtensionConfig = {
|
|||||||
*/
|
*/
|
||||||
bundled?: boolean | null;
|
bundled?: boolean | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
env_keys?: Array<string>;
|
||||||
envs?: Envs;
|
envs?: Envs;
|
||||||
/**
|
/**
|
||||||
* The name used to identify this extension
|
* The name used to identify this extension
|
||||||
@@ -45,6 +46,7 @@ export type ExtensionConfig = {
|
|||||||
bundled?: boolean | null;
|
bundled?: boolean | null;
|
||||||
cmd: string;
|
cmd: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
env_keys?: Array<string>;
|
||||||
envs?: Envs;
|
envs?: Envs;
|
||||||
/**
|
/**
|
||||||
* The name used to identify this extension
|
* The name used to identify this extension
|
||||||
@@ -327,6 +329,29 @@ export type ReadAllConfigResponses = {
|
|||||||
|
|
||||||
export type ReadAllConfigResponse = ReadAllConfigResponses[keyof ReadAllConfigResponses];
|
export type ReadAllConfigResponse = ReadAllConfigResponses[keyof ReadAllConfigResponses];
|
||||||
|
|
||||||
|
export type BackupConfigData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/config/backup';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BackupConfigErrors = {
|
||||||
|
/**
|
||||||
|
* Internal server error
|
||||||
|
*/
|
||||||
|
500: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BackupConfigResponses = {
|
||||||
|
/**
|
||||||
|
* Config file backed up
|
||||||
|
*/
|
||||||
|
200: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BackupConfigResponse = BackupConfigResponses[keyof BackupConfigResponses];
|
||||||
|
|
||||||
export type GetExtensionsData = {
|
export type GetExtensionsData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import Back from './icons/Back';
|
|||||||
import { Bars } from './icons/Bars';
|
import { Bars } from './icons/Bars';
|
||||||
import { Geese } from './icons/Geese';
|
import { Geese } from './icons/Geese';
|
||||||
import Copy from './icons/Copy';
|
import Copy from './icons/Copy';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
import { useConfig } from './ConfigContext';
|
import { useConfig } from './ConfigContext';
|
||||||
import { FixedExtensionEntry } from './ConfigContext';
|
import { FixedExtensionEntry } from './ConfigContext';
|
||||||
import ExtensionList from './settings_v2/extensions/subcomponents/ExtensionList';
|
// import ExtensionList from './settings_v2/extensions/subcomponents/ExtensionList';
|
||||||
import { Check } from 'lucide-react';
|
|
||||||
|
|
||||||
interface RecipeEditorProps {
|
interface RecipeEditorProps {
|
||||||
config?: Recipe;
|
config?: Recipe;
|
||||||
@@ -30,11 +30,11 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
const [instructions, setInstructions] = useState(config?.instructions || '');
|
const [instructions, setInstructions] = useState(config?.instructions || '');
|
||||||
const [activities, setActivities] = useState<string[]>(config?.activities || []);
|
const [activities, setActivities] = useState<string[]>(config?.activities || []);
|
||||||
const [extensionOptions, setExtensionOptions] = useState<FixedExtensionEntry[]>([]);
|
const [extensionOptions, setExtensionOptions] = useState<FixedExtensionEntry[]>([]);
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const [extensionsLoaded, setExtensionsLoaded] = useState(false);
|
const [extensionsLoaded, setExtensionsLoaded] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
// Initialize selected extensions for the recipe from config or localStorage
|
// Initialize selected extensions for the recipe from config or localStorage
|
||||||
const [recipeExtensions, setRecipeExtensions] = useState<string[]>(() => {
|
const [recipeExtensions] = useState<string[]>(() => {
|
||||||
// First try to get from localStorage
|
// First try to get from localStorage
|
||||||
const stored = localStorage.getItem('recipe_editor_extensions');
|
const stored = localStorage.getItem('recipe_editor_extensions');
|
||||||
if (stored) {
|
if (stored) {
|
||||||
@@ -95,20 +95,20 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [recipeExtensions, extensionsLoaded]);
|
}, [recipeExtensions, extensionsLoaded]);
|
||||||
|
|
||||||
const handleExtensionToggle = (extension: FixedExtensionEntry) => {
|
// const handleExtensionToggle = (extension: FixedExtensionEntry) => {
|
||||||
console.log('Toggling extension:', extension.name);
|
// console.log('Toggling extension:', extension.name);
|
||||||
setRecipeExtensions((prev) => {
|
// setRecipeExtensions((prev) => {
|
||||||
const isSelected = prev.includes(extension.name);
|
// const isSelected = prev.includes(extension.name);
|
||||||
const newState = isSelected
|
// const newState = isSelected
|
||||||
? prev.filter((extName) => extName !== extension.name)
|
// ? prev.filter((extName) => extName !== extension.name)
|
||||||
: [...prev, extension.name];
|
// : [...prev, extension.name];
|
||||||
|
|
||||||
// Persist to localStorage
|
// // Persist to localStorage
|
||||||
localStorage.setItem('recipe_editor_extensions', JSON.stringify(newState));
|
// localStorage.setItem('recipe_editor_extensions', JSON.stringify(newState));
|
||||||
|
|
||||||
return newState;
|
// return newState;
|
||||||
});
|
// });
|
||||||
};
|
// };
|
||||||
|
|
||||||
const handleAddActivity = () => {
|
const handleAddActivity = () => {
|
||||||
if (newActivity.trim()) {
|
if (newActivity.trim()) {
|
||||||
@@ -143,14 +143,9 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
// Create a clean copy of the extension configuration
|
// Create a clean copy of the extension configuration
|
||||||
const cleanExtension = { ...extension };
|
const cleanExtension = { ...extension };
|
||||||
delete cleanExtension.enabled;
|
delete cleanExtension.enabled;
|
||||||
|
// Remove legacy envs which could potentially include secrets
|
||||||
// If the extension has env_keys, preserve keys but clear values
|
// env_keys will work but rely on the end user having setup those keys themselves
|
||||||
if (cleanExtension.env_keys) {
|
delete cleanExtension.envs;
|
||||||
cleanExtension.env_keys = Object.fromEntries(
|
|
||||||
Object.keys(cleanExtension.env_keys).map((key) => [key, ''])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return cleanExtension;
|
return cleanExtension;
|
||||||
})
|
})
|
||||||
.filter(Boolean) as FullExtensionConfig[],
|
.filter(Boolean) as FullExtensionConfig[],
|
||||||
@@ -173,31 +168,13 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenAgent = () => {
|
|
||||||
if (validateForm()) {
|
|
||||||
const updatedConfig = getCurrentConfig();
|
|
||||||
// Clear stored extensions when submitting
|
|
||||||
localStorage.removeItem('recipe_editor_extensions');
|
|
||||||
window.electron.createChatWindow(
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
updatedConfig,
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deeplink = generateDeepLink(getCurrentConfig());
|
const deeplink = generateDeepLink(getCurrentConfig());
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
// Copy the text to the clipboard
|
|
||||||
navigator.clipboard
|
navigator.clipboard
|
||||||
.writeText(deeplink)
|
.writeText(deeplink)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setCopied(true); // Show the check mark
|
setCopied(true);
|
||||||
// Reset to normal after 2 seconds (2000 milliseconds)
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -293,30 +270,30 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'extensions':
|
// case 'extensions':
|
||||||
return (
|
// return (
|
||||||
<div className="p-6 pt-10">
|
// <div className="p-6 pt-10">
|
||||||
<button onClick={() => setActiveSection('none')} className="mb-6">
|
// <button onClick={() => setActiveSection('none')} className="mb-6">
|
||||||
<Back className="w-6 h-6 text-iconProminent" />
|
// <Back className="w-6 h-6 text-iconProminent" />
|
||||||
</button>
|
// </button>
|
||||||
<div className="py-2">
|
// <div className="py-2">
|
||||||
<Bars className="w-6 h-6 text-iconSubtle" />
|
// <Bars className="w-6 h-6 text-iconSubtle" />
|
||||||
</div>
|
// </div>
|
||||||
<div className="mb-8 mt-6">
|
// <div className="mb-8 mt-6">
|
||||||
<h2 className="text-2xl font-medium mb-2 text-textProminent">Extensions</h2>
|
// <h2 className="text-2xl font-medium mb-2 text-textProminent">Extensions</h2>
|
||||||
<p className="text-textSubtle">Select extensions to bundle in the recipe</p>
|
// <p className="text-textSubtle">Select extensions to bundle in the recipe</p>
|
||||||
</div>
|
// </div>
|
||||||
{extensionsLoaded ? (
|
// {extensionsLoaded ? (
|
||||||
<ExtensionList
|
// <ExtensionList
|
||||||
extensions={extensionOptions}
|
// extensions={extensionOptions}
|
||||||
onToggle={handleExtensionToggle}
|
// onToggle={handleExtensionToggle}
|
||||||
isStatic={true}
|
// isStatic={true}
|
||||||
/>
|
// />
|
||||||
) : (
|
// ) : (
|
||||||
<div className="text-center py-8 text-textSubtle">Loading extensions...</div>
|
// <div className="text-center py-8 text-textSubtle">Loading extensions...</div>
|
||||||
)}
|
// )}
|
||||||
</div>
|
// </div>
|
||||||
);
|
// );
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
@@ -385,7 +362,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
|
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
{/* <button
|
||||||
onClick={() => setActiveSection('extensions')}
|
onClick={() => setActiveSection('extensions')}
|
||||||
className="w-full flex items-start justify-between p-4 border border-borderSubtle rounded-lg bg-bgApp hover:bg-bgSubtle"
|
className="w-full flex items-start justify-between p-4 border border-borderSubtle rounded-lg bg-bgApp hover:bg-bgSubtle"
|
||||||
>
|
>
|
||||||
@@ -396,33 +373,48 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
|
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
|
||||||
</button>
|
</button> */}
|
||||||
|
|
||||||
{/* Deep Link Display */}
|
{/* Deep Link Display */}
|
||||||
<div className="w-full p-4 bg-bgSubtle rounded-lg flex items-center justify-between">
|
<div className="w-full p-4 bg-bgSubtle rounded-lg">
|
||||||
<code className="text-sm text-textSubtle truncate">{deeplink}</code>
|
<div className="flex items-center justify-between mb-2">
|
||||||
<button
|
<div className="text-sm text-textSubtle text-xs text-textSubtle mt-2">
|
||||||
onClick={handleCopy}
|
Copy this link to share with friends or paste directly in Chrome to open
|
||||||
className="ml-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
</div>
|
||||||
disabled={!title.trim() || !description.trim()}
|
<button
|
||||||
|
onClick={() => validateForm() && handleCopy()}
|
||||||
|
className="ml-4 p-2 hover:bg-bgApp rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent"
|
||||||
|
title={
|
||||||
|
!title.trim() || !description.trim()
|
||||||
|
? 'Fill in required fields first'
|
||||||
|
: 'Copy link'
|
||||||
|
}
|
||||||
|
disabled={!title.trim() || !description.trim()}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="w-4 h-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4 text-iconSubtle" />
|
||||||
|
)}
|
||||||
|
<span className="ml-1 text-sm text-textSubtle">
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-sm truncate font-mono ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`}
|
||||||
|
title={
|
||||||
|
!title.trim() || !description.trim()
|
||||||
|
? 'Fill in required fields to generate link'
|
||||||
|
: deeplink
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{copied ? (
|
{deeplink}
|
||||||
<Check className="w-5 h-5 text-green-500" />
|
</div>
|
||||||
) : (
|
|
||||||
<Copy className="w-5 h-5 text-iconSubtle" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex flex-col space-y-2 pt-1">
|
<div className="flex flex-col space-y-2 pt-1">
|
||||||
<button
|
|
||||||
onClick={handleOpenAgent}
|
|
||||||
className="w-full p-3 bg-bgAppInverse text-textProminentInverse rounded-lg hover:bg-bgStandardInverse disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
disabled={!title.trim() || !description.trim()}
|
|
||||||
>
|
|
||||||
Open agent
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
localStorage.removeItem('recipe_editor_extensions');
|
localStorage.removeItem('recipe_editor_extensions');
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { ExtensionConfig } from '../../../api/types.gen';
|
|||||||
import { getApiUrl, getSecretKey } from '../../../config';
|
import { getApiUrl, getSecretKey } from '../../../config';
|
||||||
import { toastService, ToastServiceOptions } from '../../../toasts';
|
import { toastService, ToastServiceOptions } from '../../../toasts';
|
||||||
import { replaceWithShims } from './utils';
|
import { replaceWithShims } from './utils';
|
||||||
import { saveEnvVarsToKeyring } from './extension-manager';
|
|
||||||
import type { AgentExtensionConfig } from './extension-manager';
|
|
||||||
|
|
||||||
interface ApiResponse {
|
interface ApiResponse {
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
@@ -143,17 +141,13 @@ export async function addToAgent(
|
|||||||
options: ToastServiceOptions = {}
|
options: ToastServiceOptions = {}
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
await saveEnvVarsToKeyring(extension);
|
if (extension.type === 'stdio') {
|
||||||
|
extension.cmd = await replaceWithShims(extension.cmd);
|
||||||
const ext = toAgentExtensionConfig(extension);
|
|
||||||
|
|
||||||
if (ext.type === 'stdio') {
|
|
||||||
ext.cmd = await replaceWithShims(ext.cmd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ext.name = sanitizeName(ext.name);
|
extension.name = sanitizeName(extension.name);
|
||||||
|
|
||||||
return await extensionApiCall('/extensions/add', ext, options);
|
return await extensionApiCall('/extensions/add', extension, options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Check if this is a 428 error and make the message more descriptive
|
// Check if this is a 428 error and make the message more descriptive
|
||||||
if (error.message && error.message.includes('428')) {
|
if (error.message && error.message.includes('428')) {
|
||||||
@@ -185,32 +179,3 @@ export async function removeFromAgent(
|
|||||||
function sanitizeName(name: string) {
|
function sanitizeName(name: string) {
|
||||||
return name.toLowerCase().replace(/-/g, '').replace(/_/g, '').replace(/\s/g, '');
|
return name.toLowerCase().replace(/-/g, '').replace(/_/g, '').replace(/\s/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toAgentExtensionConfig(config: ExtensionConfig): AgentExtensionConfig {
|
|
||||||
// Use type narrowing to handle different variants of the union type
|
|
||||||
if ('type' in config) {
|
|
||||||
switch (config.type) {
|
|
||||||
case 'sse': {
|
|
||||||
const { envs, ...rest } = config;
|
|
||||||
return {
|
|
||||||
...rest,
|
|
||||||
env_keys: envs ? Object.keys(envs) : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'stdio': {
|
|
||||||
const { envs, ...rest } = config;
|
|
||||||
return {
|
|
||||||
...rest,
|
|
||||||
env_keys: envs ? Object.keys(envs) : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'builtin':
|
|
||||||
case 'frontend':
|
|
||||||
// These types don't have envs field, so just return as is
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This should never happen due to the union type constraint
|
|
||||||
throw new Error('Invalid extension configuration type');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import type { ExtensionConfig } from '../../../api/types.gen';
|
import type { ExtensionConfig } from '../../../api/types.gen';
|
||||||
import { toastService, ToastServiceOptions } from '../../../toasts';
|
import { toastService, ToastServiceOptions } from '../../../toasts';
|
||||||
import { addToAgent, removeFromAgent } from './agent-api';
|
import { addToAgent, removeFromAgent } from './agent-api';
|
||||||
import { upsertConfig } from '../../../api';
|
|
||||||
|
|
||||||
// TODO: unify config.yaml and the agent /extensions/add API's notion of env vars
|
|
||||||
export type AgentExtensionConfig = ExtensionConfig & {
|
|
||||||
env_keys?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ActivateExtensionProps {
|
interface ActivateExtensionProps {
|
||||||
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
|
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
|
||||||
@@ -289,11 +283,3 @@ export async function deleteExtension({ name, removeFromConfig }: DeleteExtensio
|
|||||||
throw agentRemoveError;
|
throw agentRemoveError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveEnvVarsToKeyring(extension: ExtensionConfig) {
|
|
||||||
if (extension.type === 'stdio' || extension.type === 'sse') {
|
|
||||||
for (const [key, value] of Object.entries(extension.envs || {})) {
|
|
||||||
await upsertConfig({ body: { key, value, is_secret: true } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from '../../../ui/button';
|
import { Button } from '../../../ui/button';
|
||||||
import { Plus, X } from 'lucide-react';
|
import { Plus, X, Edit } from 'lucide-react';
|
||||||
import { Input } from '../../../ui/input';
|
import { Input } from '../../../ui/input';
|
||||||
import { cn } from '../../../../utils';
|
import { cn } from '../../../../utils';
|
||||||
|
|
||||||
interface EnvVarsSectionProps {
|
interface EnvVarsSectionProps {
|
||||||
envVars: { key: string; value: string }[];
|
envVars: { key: string; value: string; isEdited?: boolean }[];
|
||||||
onAdd: (key: string, value: string) => void;
|
onAdd: (key: string, value: string) => void;
|
||||||
onRemove: (index: number) => void;
|
onRemove: (index: number) => void;
|
||||||
onChange: (index: number, field: 'key' | 'value', value: string) => void;
|
onChange: (index: number, field: 'key' | 'value', value: string) => void;
|
||||||
@@ -68,6 +68,21 @@ export default function EnvVarsSection({
|
|||||||
return value === '';
|
return value === '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEdit = (index: number) => {
|
||||||
|
// Mark this env var as edited
|
||||||
|
onChange(index, 'value', envVars[index].value === '••••••••' ? '' : envVars[index].value);
|
||||||
|
|
||||||
|
// Mark as edited in the parent component
|
||||||
|
const updatedEnvVar = {
|
||||||
|
...envVars[index],
|
||||||
|
isEdited: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the envVars array with the edited flag
|
||||||
|
const newEnvVars = [...envVars];
|
||||||
|
newEnvVars[index] = updatedEnvVar;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="relative mb-2">
|
<div className="relative mb-2">
|
||||||
@@ -76,10 +91,10 @@ export default function EnvVarsSection({
|
|||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-textSubtle mb-4">
|
<p className="text-xs text-textSubtle mb-4">
|
||||||
Add key-value pairs for environment variables. Click the "+" button to add after filling
|
Add key-value pairs for environment variables. Click the "+" button to add after filling
|
||||||
both fields.
|
both fields. For existing secret values, click the edit button to modify.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center">
|
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
|
||||||
{/* Existing environment variables */}
|
{/* Existing environment variables */}
|
||||||
{envVars.map((envVar, index) => (
|
{envVars.map((envVar, index) => (
|
||||||
<React.Fragment key={index}>
|
<React.Fragment key={index}>
|
||||||
@@ -97,18 +112,39 @@ export default function EnvVarsSection({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
value={envVar.value}
|
value={envVar.value}
|
||||||
onChange={(e) => onChange(index, 'value', e.target.value)}
|
readOnly={envVar.value === '••••••••' && !envVar.isEdited}
|
||||||
|
onChange={(e) => {
|
||||||
|
// If this is the first edit of a placeholder value, clear it
|
||||||
|
const newValue =
|
||||||
|
envVar.value === '••••••••' && !envVar.isEdited ? '' : e.target.value;
|
||||||
|
onChange(index, 'value', newValue);
|
||||||
|
}}
|
||||||
placeholder="Value"
|
placeholder="Value"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-textStandard border-borderSubtle hover:border-borderStandard',
|
'w-full border-borderSubtle',
|
||||||
|
envVar.value === '••••••••' && !envVar.isEdited
|
||||||
|
? 'text-textSubtle opacity-60 cursor-not-allowed hover:border-borderSubtle'
|
||||||
|
: 'text-textStandard hover:border-borderStandard',
|
||||||
isFieldInvalid(index, 'value') && 'border-red-500 focus:border-red-500'
|
isFieldInvalid(index, 'value') && 'border-red-500 focus:border-red-500'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{envVar.value === '••••••••' && !envVar.isEdited && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleEdit(index)}
|
||||||
|
variant="ghost"
|
||||||
|
className="group p-2 h-auto text-iconSubtle hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<Edit className="h-3 w-3 text-gray-400 group-hover:text-white group-hover:drop-shadow-sm transition-all" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(envVar.value !== '••••••••' || envVar.isEdited) && (
|
||||||
|
<div className="w-8 h-8"></div> /* Empty div to maintain grid spacing */
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onRemove(index)}
|
onClick={() => onRemove(index)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="group p-2 h-auto text-iconSubtle hover:bg-transparent min-w-[60px] flex justify-start"
|
className="group p-2 h-auto text-iconSubtle hover:bg-transparent"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3 text-gray-400 group-hover:text-white group-hover:drop-shadow-sm transition-all" />
|
<X className="h-3 w-3 text-gray-400 group-hover:text-white group-hover:drop-shadow-sm transition-all" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -140,13 +176,15 @@ export default function EnvVarsSection({
|
|||||||
invalidFields.value && 'border-red-500 focus:border-red-500'
|
invalidFields.value && 'border-red-500 focus:border-red-500'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<div className="col-span-2">
|
||||||
onClick={handleAdd}
|
<Button
|
||||||
variant="ghost"
|
onClick={handleAdd}
|
||||||
className="flex items-center justify-start gap-1 px-2 pr-4 text-sm rounded-full text-textStandard bg-bgApp border border-borderSubtle hover:border-borderStandard transition-colors min-w-[60px] h-9 [&>svg]:!size-4"
|
variant="ghost"
|
||||||
>
|
className="flex items-center justify-start gap-1 px-2 pr-4 text-sm rounded-full text-textStandard bg-bgApp border border-borderSubtle hover:border-borderStandard transition-colors min-w-[60px] h-9 [&>svg]:!size-4"
|
||||||
<Plus /> Add
|
>
|
||||||
</Button>
|
<Plus /> Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{validationError && <div className="mt-2 text-red-500 text-sm">{validationError}</div>}
|
{validationError && <div className="mt-2 text-red-500 text-sm">{validationError}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ExtensionConfigFields from './ExtensionConfigFields';
|
|||||||
import { PlusIcon, Edit, Trash2, AlertTriangle } from 'lucide-react';
|
import { PlusIcon, Edit, Trash2, AlertTriangle } from 'lucide-react';
|
||||||
import ExtensionInfoFields from './ExtensionInfoFields';
|
import ExtensionInfoFields from './ExtensionInfoFields';
|
||||||
import ExtensionTimeoutField from './ExtensionTimeoutField';
|
import ExtensionTimeoutField from './ExtensionTimeoutField';
|
||||||
|
import { upsertConfig } from '../../../../api/sdk.gen';
|
||||||
|
|
||||||
interface ExtensionModalProps {
|
interface ExtensionModalProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -34,7 +35,7 @@ export default function ExtensionModal({
|
|||||||
const handleAddEnvVar = (key: string, value: string) => {
|
const handleAddEnvVar = (key: string, value: string) => {
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
envVars: [...formData.envVars, { key, value }],
|
envVars: [...formData.envVars, { key, value, isEdited: true }],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,12 +51,35 @@ export default function ExtensionModal({
|
|||||||
const handleEnvVarChange = (index: number, field: 'key' | 'value', value: string) => {
|
const handleEnvVarChange = (index: number, field: 'key' | 'value', value: string) => {
|
||||||
const newEnvVars = [...formData.envVars];
|
const newEnvVars = [...formData.envVars];
|
||||||
newEnvVars[index][field] = value;
|
newEnvVars[index][field] = value;
|
||||||
|
|
||||||
|
// Mark as edited if it's a value change
|
||||||
|
if (field === 'value') {
|
||||||
|
newEnvVars[index].isEdited = true;
|
||||||
|
}
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
envVars: newEnvVars,
|
envVars: newEnvVars,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Function to store a secret value
|
||||||
|
const storeSecret = async (key: string, value: string) => {
|
||||||
|
try {
|
||||||
|
await upsertConfig({
|
||||||
|
body: {
|
||||||
|
is_secret: true,
|
||||||
|
key: key,
|
||||||
|
value: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to store secret:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Function to determine which icon to display with proper styling
|
// Function to determine which icon to display with proper styling
|
||||||
const getModalIcon = () => {
|
const getModalIcon = () => {
|
||||||
if (showDeleteConfirmation) {
|
if (showDeleteConfirmation) {
|
||||||
@@ -104,23 +128,36 @@ export default function ExtensionModal({
|
|||||||
return isNameValid() && isConfigValid() && isEnvVarsValid() && isTimeoutValid();
|
return isNameValid() && isConfigValid() && isEnvVarsValid() && isTimeoutValid();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle submit with validation
|
// Handle submit with validation and secret storage
|
||||||
const handleSubmit = () => {
|
const handleSubmit = async () => {
|
||||||
setSubmitAttempted(true);
|
setSubmitAttempted(true);
|
||||||
|
|
||||||
if (isFormValid()) {
|
if (isFormValid()) {
|
||||||
const dataToSubmit = { ...formData };
|
// Only store env vars that have been edited (which includes new)
|
||||||
|
const secretPromises = formData.envVars
|
||||||
|
.filter((envVar) => envVar.isEdited)
|
||||||
|
.map(({ key, value }) => storeSecret(key, value));
|
||||||
|
|
||||||
// Convert the timeout to a number if it's a string
|
try {
|
||||||
if (typeof dataToSubmit.timeout === 'string') {
|
// Wait for all secrets to be stored
|
||||||
dataToSubmit.timeout = Number(dataToSubmit.timeout);
|
const results = await Promise.all(secretPromises);
|
||||||
|
|
||||||
|
if (results.every((success) => success)) {
|
||||||
|
// Convert timeout to number if needed
|
||||||
|
const dataToSubmit = {
|
||||||
|
...formData,
|
||||||
|
timeout:
|
||||||
|
typeof formData.timeout === 'string' ? Number(formData.timeout) : formData.timeout,
|
||||||
|
};
|
||||||
|
onSubmit(dataToSubmit);
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
console.error('Failed to store one or more secrets');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during submission:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit the data with converted timeout
|
|
||||||
onSubmit(dataToSubmit);
|
|
||||||
onClose(); // Only close the modal if the form is valid
|
|
||||||
} else {
|
} else {
|
||||||
// Optional: Add some feedback that validation failed (like a toast notification)
|
|
||||||
console.log('Form validation failed');
|
console.log('Form validation failed');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -241,7 +278,7 @@ export default function ExtensionModal({
|
|||||||
envVars={formData.envVars}
|
envVars={formData.envVars}
|
||||||
onAdd={handleAddEnvVar}
|
onAdd={handleAddEnvVar}
|
||||||
onRemove={handleRemoveEnvVar}
|
onRemove={handleRemoveEnvVar}
|
||||||
onChange={Object.assign(handleEnvVarChange, { setSubmitAttempted })}
|
onChange={handleEnvVarChange}
|
||||||
submitAttempted={submitAttempted}
|
submitAttempted={submitAttempted}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ export interface ExtensionFormData {
|
|||||||
endpoint?: string;
|
endpoint?: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
envVars: { key: string; value: string }[];
|
envVars: {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
isEdited?: boolean;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultFormData(): ExtensionFormData {
|
export function getDefaultFormData(): ExtensionFormData {
|
||||||
@@ -46,13 +50,30 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo
|
|||||||
// Type guard: Check if 'envs' property exists for this variant
|
// Type guard: Check if 'envs' property exists for this variant
|
||||||
const hasEnvs = extension.type === 'sse' || extension.type === 'stdio';
|
const hasEnvs = extension.type === 'sse' || extension.type === 'stdio';
|
||||||
|
|
||||||
const envVars =
|
// Handle both envs (legacy) and env_keys (new secrets)
|
||||||
hasEnvs && extension.envs
|
let envVars = [];
|
||||||
? Object.entries(extension.envs).map(([key, value]) => ({
|
|
||||||
key,
|
// Add legacy envs with their values
|
||||||
value: value as string,
|
if (hasEnvs && extension.envs) {
|
||||||
}))
|
envVars.push(
|
||||||
: [];
|
...Object.entries(extension.envs).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: value as string,
|
||||||
|
isEdited: true, // We want to submit legacy values as secrets to migrate forward
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add env_keys with placeholder values
|
||||||
|
if (hasEnvs && extension.env_keys) {
|
||||||
|
envVars.push(
|
||||||
|
...extension.env_keys.map((key) => ({
|
||||||
|
key,
|
||||||
|
value: '••••••••', // Placeholder for secret values
|
||||||
|
isEdited: false, // Mark as not edited initially
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: extension.name,
|
name: extension.name,
|
||||||
@@ -68,15 +89,8 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createExtensionConfig(formData: ExtensionFormData): ExtensionConfig {
|
export function createExtensionConfig(formData: ExtensionFormData): ExtensionConfig {
|
||||||
const envs = formData.envVars.reduce(
|
// Extract just the keys from env vars
|
||||||
(acc, { key, value }) => {
|
const env_keys = formData.envVars.map(({ key }) => key).filter((key) => key.length > 0);
|
||||||
if (key) {
|
|
||||||
acc[key] = value;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (formData.type === 'stdio') {
|
if (formData.type === 'stdio') {
|
||||||
// we put the cmd + args all in the form cmd field but need to split out into cmd + args
|
// we put the cmd + args all in the form cmd field but need to split out into cmd + args
|
||||||
@@ -89,7 +103,7 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon
|
|||||||
cmd: cmd,
|
cmd: cmd,
|
||||||
args: args,
|
args: args,
|
||||||
timeout: formData.timeout,
|
timeout: formData.timeout,
|
||||||
...(Object.keys(envs).length > 0 ? { envs } : {}),
|
...(env_keys.length > 0 ? { env_keys } : {}),
|
||||||
};
|
};
|
||||||
} else if (formData.type === 'sse') {
|
} else if (formData.type === 'sse') {
|
||||||
return {
|
return {
|
||||||
@@ -97,8 +111,8 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon
|
|||||||
name: formData.name,
|
name: formData.name,
|
||||||
description: formData.description,
|
description: formData.description,
|
||||||
timeout: formData.timeout,
|
timeout: formData.timeout,
|
||||||
uri: formData.endpoint, // Assuming endpoint maps to uri for SSE type
|
uri: formData.endpoint,
|
||||||
...(Object.keys(envs).length > 0 ? { envs } : {}),
|
...(env_keys.length > 0 ? { env_keys } : {}),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// For other types
|
// For other types
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ There may be (but not always) some tools mentioned in the instructions which you
|
|||||||
*
|
*
|
||||||
* @param addExtension Function to add extension to config.yaml
|
* @param addExtension Function to add extension to config.yaml
|
||||||
*/
|
*/
|
||||||
export const migrateExtensionsToSettingsV2 = async () => {
|
export const migrateExtensionsToSettingsV3 = async () => {
|
||||||
console.log('need to perform extension migration');
|
console.log('need to perform extension migration v3');
|
||||||
|
|
||||||
const userSettingsStr = localStorage.getItem('user_settings');
|
const userSettingsStr = localStorage.getItem('user_settings');
|
||||||
let localStorageExtensions: FullExtensionConfig[] = [];
|
let localStorageExtensions: FullExtensionConfig[] = [];
|
||||||
@@ -140,8 +140,8 @@ export const migrateExtensionsToSettingsV2 = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (migrationErrors.length === 0) {
|
if (migrationErrors.length === 0) {
|
||||||
localStorage.setItem('configVersion', '2');
|
localStorage.setItem('configVersion', '3');
|
||||||
console.log('Extension migration complete. Config version set to 2.');
|
console.log('Extension migration complete. Config version set to 3.');
|
||||||
} else {
|
} else {
|
||||||
const errorSummaryStr = migrationErrors
|
const errorSummaryStr = migrationErrors
|
||||||
.map(({ name, error }) => `- ${name}: ${JSON.stringify(error)}`)
|
.map(({ name, error }) => `- ${name}: ${JSON.stringify(error)}`)
|
||||||
@@ -209,11 +209,11 @@ export const initializeSystem = async (
|
|||||||
// NOTE: remove when we want to stop migration logic
|
// NOTE: remove when we want to stop migration logic
|
||||||
// Check if we need to migrate extensions from localStorage to config.yaml
|
// Check if we need to migrate extensions from localStorage to config.yaml
|
||||||
const configVersion = localStorage.getItem('configVersion');
|
const configVersion = localStorage.getItem('configVersion');
|
||||||
const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 2;
|
const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3;
|
||||||
|
|
||||||
console.log(`shouldMigrateExtensions is ${shouldMigrateExtensions}`);
|
console.log(`shouldMigrateExtensions is ${shouldMigrateExtensions}`);
|
||||||
if (shouldMigrateExtensions) {
|
if (shouldMigrateExtensions) {
|
||||||
await migrateExtensionsToSettingsV2();
|
await migrateExtensionsToSettingsV3();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* NOTE:
|
/* NOTE:
|
||||||
|
|||||||
Reference in New Issue
Block a user