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
+35
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": {
"get": {
"tags": [
@@ -466,6 +489,12 @@
"type": "string",
"nullable": true
},
"env_keys": {
"type": "array",
"items": {
"type": "string"
}
},
"envs": {
"$ref": "#/components/schemas/Envs"
},
@@ -518,6 +547,12 @@
"type": "string",
"nullable": true
},
"env_keys": {
"type": "array",
"items": {
"type": "string"
}
},
"envs": {
"$ref": "#/components/schemas/Envs"
},
+23 -7
View File
@@ -34,7 +34,7 @@ import { addExtension as addExtensionDirect, FullExtensionConfig } from './exten
import 'react-toastify/dist/ReactToastify.css';
import { useConfig, MalformedConfigError } from './components/ConfigContext';
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';
// Views and their options
@@ -240,7 +240,7 @@ export default function App() {
console.log('Finished enabling bot config extensions');
};
const enableRecipeConfigExtensionsV2 = useCallback(
const _enableRecipeConfigExtensionsV2 = useCallback(
async (extensions: FullExtensionConfig[]) => {
if (!extensions?.length) {
console.log('No extensions to enable from bot config');
@@ -299,9 +299,25 @@ export default function App() {
const initializeApp = async () => {
try {
// Initialize config first
// checks if there is a config, and if not creates it
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
if (recipeConfig === null) {
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
if (recipeConfig?.extensions?.length > 0 && viewType != 'recipeEditor') {
console.log('Found extensions in bot config:', recipeConfig.extensions);
await enableRecipeConfigExtensionsV2(recipeConfig.extensions);
}
// if (recipeConfig?.extensions?.length > 0 && viewType != 'recipeEditor') {
// console.log('Found extensions in bot config:', recipeConfig.extensions);
// await enableRecipeConfigExtensionsV2(recipeConfig.extensions);
// }
const config = window.electron.getConfig();
+8 -1
View File
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
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';
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>) => {
return (options?.client ?? _heyApiClient).get<GetExtensionsResponse, unknown, ThrowOnError>({
url: '/config/extensions',
+25
View File
@@ -29,6 +29,7 @@ export type ExtensionConfig = {
*/
bundled?: boolean | null;
description?: string | null;
env_keys?: Array<string>;
envs?: Envs;
/**
* The name used to identify this extension
@@ -45,6 +46,7 @@ export type ExtensionConfig = {
bundled?: boolean | null;
cmd: string;
description?: string | null;
env_keys?: Array<string>;
envs?: Envs;
/**
* The name used to identify this extension
@@ -327,6 +329,29 @@ export type 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 = {
body?: never;
path?: never;
+80 -88
View File
@@ -7,10 +7,10 @@ import Back from './icons/Back';
import { Bars } from './icons/Bars';
import { Geese } from './icons/Geese';
import Copy from './icons/Copy';
import { Check } from 'lucide-react';
import { useConfig } from './ConfigContext';
import { FixedExtensionEntry } from './ConfigContext';
import ExtensionList from './settings_v2/extensions/subcomponents/ExtensionList';
import { Check } from 'lucide-react';
// import ExtensionList from './settings_v2/extensions/subcomponents/ExtensionList';
interface RecipeEditorProps {
config?: Recipe;
@@ -30,11 +30,11 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
const [instructions, setInstructions] = useState(config?.instructions || '');
const [activities, setActivities] = useState<string[]>(config?.activities || []);
const [extensionOptions, setExtensionOptions] = useState<FixedExtensionEntry[]>([]);
const [copied, setCopied] = useState(false);
const [extensionsLoaded, setExtensionsLoaded] = useState(false);
const [copied, setCopied] = useState(false);
// 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
const stored = localStorage.getItem('recipe_editor_extensions');
if (stored) {
@@ -95,20 +95,20 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recipeExtensions, extensionsLoaded]);
const handleExtensionToggle = (extension: FixedExtensionEntry) => {
console.log('Toggling extension:', extension.name);
setRecipeExtensions((prev) => {
const isSelected = prev.includes(extension.name);
const newState = isSelected
? prev.filter((extName) => extName !== extension.name)
: [...prev, extension.name];
// const handleExtensionToggle = (extension: FixedExtensionEntry) => {
// console.log('Toggling extension:', extension.name);
// setRecipeExtensions((prev) => {
// const isSelected = prev.includes(extension.name);
// const newState = isSelected
// ? prev.filter((extName) => extName !== extension.name)
// : [...prev, extension.name];
// Persist to localStorage
localStorage.setItem('recipe_editor_extensions', JSON.stringify(newState));
// // Persist to localStorage
// localStorage.setItem('recipe_editor_extensions', JSON.stringify(newState));
return newState;
});
};
// return newState;
// });
// };
const handleAddActivity = () => {
if (newActivity.trim()) {
@@ -143,14 +143,9 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
// Create a clean copy of the extension configuration
const cleanExtension = { ...extension };
delete cleanExtension.enabled;
// If the extension has env_keys, preserve keys but clear values
if (cleanExtension.env_keys) {
cleanExtension.env_keys = Object.fromEntries(
Object.keys(cleanExtension.env_keys).map((key) => [key, ''])
);
}
// Remove legacy envs which could potentially include secrets
// env_keys will work but rely on the end user having setup those keys themselves
delete cleanExtension.envs;
return cleanExtension;
})
.filter(Boolean) as FullExtensionConfig[],
@@ -173,31 +168,13 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
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 handleCopy = () => {
// Copy the text to the clipboard
navigator.clipboard
.writeText(deeplink)
.then(() => {
setCopied(true); // Show the check mark
// Reset to normal after 2 seconds (2000 milliseconds)
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch((err) => {
@@ -293,30 +270,30 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
</div>
);
case 'extensions':
return (
<div className="p-6 pt-10">
<button onClick={() => setActiveSection('none')} className="mb-6">
<Back className="w-6 h-6 text-iconProminent" />
</button>
<div className="py-2">
<Bars className="w-6 h-6 text-iconSubtle" />
</div>
<div className="mb-8 mt-6">
<h2 className="text-2xl font-medium mb-2 text-textProminent">Extensions</h2>
<p className="text-textSubtle">Select extensions to bundle in the recipe</p>
</div>
{extensionsLoaded ? (
<ExtensionList
extensions={extensionOptions}
onToggle={handleExtensionToggle}
isStatic={true}
/>
) : (
<div className="text-center py-8 text-textSubtle">Loading extensions...</div>
)}
</div>
);
// case 'extensions':
// return (
// <div className="p-6 pt-10">
// <button onClick={() => setActiveSection('none')} className="mb-6">
// <Back className="w-6 h-6 text-iconProminent" />
// </button>
// <div className="py-2">
// <Bars className="w-6 h-6 text-iconSubtle" />
// </div>
// <div className="mb-8 mt-6">
// <h2 className="text-2xl font-medium mb-2 text-textProminent">Extensions</h2>
// <p className="text-textSubtle">Select extensions to bundle in the recipe</p>
// </div>
// {extensionsLoaded ? (
// <ExtensionList
// extensions={extensionOptions}
// onToggle={handleExtensionToggle}
// isStatic={true}
// />
// ) : (
// <div className="text-center py-8 text-textSubtle">Loading extensions...</div>
// )}
// </div>
// );
default:
return (
@@ -385,7 +362,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
</button>
<button
{/* <button
onClick={() => setActiveSection('extensions')}
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>
</div>
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
</button>
</button> */}
{/* Deep Link Display */}
<div className="w-full p-4 bg-bgSubtle rounded-lg flex items-center justify-between">
<code className="text-sm text-textSubtle truncate">{deeplink}</code>
<button
onClick={handleCopy}
className="ml-2 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!title.trim() || !description.trim()}
<div className="w-full p-4 bg-bgSubtle rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-textSubtle text-xs text-textSubtle mt-2">
Copy this link to share with friends or paste directly in Chrome to open
</div>
<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 ? (
<Check className="w-5 h-5 text-green-500" />
) : (
<Copy className="w-5 h-5 text-iconSubtle" />
)}
</button>
{deeplink}
</div>
</div>
{/* Action Buttons */}
<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
onClick={() => {
localStorage.removeItem('recipe_editor_extensions');
@@ -2,8 +2,6 @@ import { ExtensionConfig } from '../../../api/types.gen';
import { getApiUrl, getSecretKey } from '../../../config';
import { toastService, ToastServiceOptions } from '../../../toasts';
import { replaceWithShims } from './utils';
import { saveEnvVarsToKeyring } from './extension-manager';
import type { AgentExtensionConfig } from './extension-manager';
interface ApiResponse {
error?: boolean;
@@ -143,17 +141,13 @@ export async function addToAgent(
options: ToastServiceOptions = {}
): Promise<Response> {
try {
await saveEnvVarsToKeyring(extension);
const ext = toAgentExtensionConfig(extension);
if (ext.type === 'stdio') {
ext.cmd = await replaceWithShims(ext.cmd);
if (extension.type === 'stdio') {
extension.cmd = await replaceWithShims(extension.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) {
// Check if this is a 428 error and make the message more descriptive
if (error.message && error.message.includes('428')) {
@@ -185,32 +179,3 @@ export async function removeFromAgent(
function sanitizeName(name: string) {
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 { toastService, ToastServiceOptions } from '../../../toasts';
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 {
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
@@ -289,11 +283,3 @@ export async function deleteExtension({ name, removeFromConfig }: DeleteExtensio
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 { Button } from '../../../ui/button';
import { Plus, X } from 'lucide-react';
import { Plus, X, Edit } from 'lucide-react';
import { Input } from '../../../ui/input';
import { cn } from '../../../../utils';
interface EnvVarsSectionProps {
envVars: { key: string; value: string }[];
envVars: { key: string; value: string; isEdited?: boolean }[];
onAdd: (key: string, value: string) => void;
onRemove: (index: number) => void;
onChange: (index: number, field: 'key' | 'value', value: string) => void;
@@ -68,6 +68,21 @@ export default function EnvVarsSection({
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 (
<div>
<div className="relative mb-2">
@@ -76,10 +91,10 @@ export default function EnvVarsSection({
</label>
<p className="text-xs text-textSubtle mb-4">
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>
</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 */}
{envVars.map((envVar, index) => (
<React.Fragment key={index}>
@@ -97,18 +112,39 @@ export default function EnvVarsSection({
<div className="relative">
<Input
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"
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'
)}
/>
</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
onClick={() => onRemove(index)}
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" />
</Button>
@@ -140,13 +176,15 @@ export default function EnvVarsSection({
invalidFields.value && 'border-red-500 focus:border-red-500'
)}
/>
<Button
onClick={handleAdd}
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>
<div className="col-span-2">
<Button
onClick={handleAdd}
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>
</div>
</div>
{validationError && <div className="mt-2 text-red-500 text-sm">{validationError}</div>}
</div>
@@ -7,6 +7,7 @@ import ExtensionConfigFields from './ExtensionConfigFields';
import { PlusIcon, Edit, Trash2, AlertTriangle } from 'lucide-react';
import ExtensionInfoFields from './ExtensionInfoFields';
import ExtensionTimeoutField from './ExtensionTimeoutField';
import { upsertConfig } from '../../../../api/sdk.gen';
interface ExtensionModalProps {
title: string;
@@ -34,7 +35,7 @@ export default function ExtensionModal({
const handleAddEnvVar = (key: string, value: string) => {
setFormData({
...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 newEnvVars = [...formData.envVars];
newEnvVars[index][field] = value;
// Mark as edited if it's a value change
if (field === 'value') {
newEnvVars[index].isEdited = true;
}
setFormData({
...formData,
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
const getModalIcon = () => {
if (showDeleteConfirmation) {
@@ -104,23 +128,36 @@ export default function ExtensionModal({
return isNameValid() && isConfigValid() && isEnvVarsValid() && isTimeoutValid();
};
// Handle submit with validation
const handleSubmit = () => {
// Handle submit with validation and secret storage
const handleSubmit = async () => {
setSubmitAttempted(true);
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
if (typeof dataToSubmit.timeout === 'string') {
dataToSubmit.timeout = Number(dataToSubmit.timeout);
try {
// Wait for all secrets to be stored
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 {
// Optional: Add some feedback that validation failed (like a toast notification)
console.log('Form validation failed');
}
};
@@ -241,7 +278,7 @@ export default function ExtensionModal({
envVars={formData.envVars}
onAdd={handleAddEnvVar}
onRemove={handleRemoveEnvVar}
onChange={Object.assign(handleEnvVarChange, { setSubmitAttempted })}
onChange={handleEnvVarChange}
submitAttempted={submitAttempted}
/>
</div>
@@ -26,7 +26,11 @@ export interface ExtensionFormData {
endpoint?: string;
enabled: boolean;
timeout?: number;
envVars: { key: string; value: string }[];
envVars: {
key: string;
value: string;
isEdited?: boolean;
}[];
}
export function getDefaultFormData(): ExtensionFormData {
@@ -46,13 +50,30 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo
// Type guard: Check if 'envs' property exists for this variant
const hasEnvs = extension.type === 'sse' || extension.type === 'stdio';
const envVars =
hasEnvs && extension.envs
? Object.entries(extension.envs).map(([key, value]) => ({
key,
value: value as string,
}))
: [];
// Handle both envs (legacy) and env_keys (new secrets)
let envVars = [];
// Add legacy envs with their values
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 {
name: extension.name,
@@ -68,15 +89,8 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo
}
export function createExtensionConfig(formData: ExtensionFormData): ExtensionConfig {
const envs = formData.envVars.reduce(
(acc, { key, value }) => {
if (key) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>
);
// Extract just the keys from env vars
const env_keys = formData.envVars.map(({ key }) => key).filter((key) => key.length > 0);
if (formData.type === 'stdio') {
// 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,
args: args,
timeout: formData.timeout,
...(Object.keys(envs).length > 0 ? { envs } : {}),
...(env_keys.length > 0 ? { env_keys } : {}),
};
} else if (formData.type === 'sse') {
return {
@@ -97,8 +111,8 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon
name: formData.name,
description: formData.description,
timeout: formData.timeout,
uri: formData.endpoint, // Assuming endpoint maps to uri for SSE type
...(Object.keys(envs).length > 0 ? { envs } : {}),
uri: formData.endpoint,
...(env_keys.length > 0 ? { env_keys } : {}),
};
} else {
// For other types
+6 -6
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
*/
export const migrateExtensionsToSettingsV2 = async () => {
console.log('need to perform extension migration');
export const migrateExtensionsToSettingsV3 = async () => {
console.log('need to perform extension migration v3');
const userSettingsStr = localStorage.getItem('user_settings');
let localStorageExtensions: FullExtensionConfig[] = [];
@@ -140,8 +140,8 @@ export const migrateExtensionsToSettingsV2 = async () => {
}
if (migrationErrors.length === 0) {
localStorage.setItem('configVersion', '2');
console.log('Extension migration complete. Config version set to 2.');
localStorage.setItem('configVersion', '3');
console.log('Extension migration complete. Config version set to 3.');
} else {
const errorSummaryStr = migrationErrors
.map(({ name, error }) => `- ${name}: ${JSON.stringify(error)}`)
@@ -209,11 +209,11 @@ export const initializeSystem = async (
// NOTE: remove when we want to stop migration logic
// Check if we need to migrate extensions from localStorage to config.yaml
const configVersion = localStorage.getItem('configVersion');
const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 2;
const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3;
console.log(`shouldMigrateExtensions is ${shouldMigrateExtensions}`);
if (shouldMigrateExtensions) {
await migrateExtensionsToSettingsV2();
await migrateExtensionsToSettingsV3();
}
/* NOTE: