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:
Bradley Axen
2025-04-18 14:01:46 -07:00
committed by GitHub
parent 621eb42fb0
commit cfb0eab9cf
19 changed files with 506 additions and 283 deletions

View File

@@ -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,

View File

@@ -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),

View File

@@ -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,

View File

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

View File

@@ -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,
} }

View File

@@ -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,

View File

@@ -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)));

View File

@@ -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()))
} }

View File

@@ -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"
}, },

View File

@@ -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();

View File

@@ -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',

View File

@@ -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;

View File

@@ -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');

View File

@@ -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');
}

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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: