Add support for SSE extensions via scheme URI (#2073)

This commit is contained in:
Jim Bennett
2025-04-10 09:10:51 -07:00
committed by GitHub
parent 4b9cb74402
commit ac56756df6
3 changed files with 155 additions and 72 deletions
+10 -1
View File
@@ -79,6 +79,12 @@ export default function App() {
return `${cmd} ${args.join(' ')}`.trim();
}
// Utility function to extract the remote url from the link
function extractRemoteUrl(link: string): string {
const url = new URL(link);
return url.searchParams.get('url');
}
const setView = (view: View, viewOptions: ViewOptions = {}) => {
console.log(`Setting view to: ${view}`, viewOptions);
setInternalView({ view, viewOptions });
@@ -257,11 +263,14 @@ export default function App() {
try {
console.log(`Received add-extension event with link: ${link}`);
const command = extractCommand(link);
const remoteUrl = extractRemoteUrl(link);
const extName = extractExtensionName(link);
window.electron.logInfo(`Adding extension from deep link ${link}`);
setPendingLink(link);
const messageDetails = remoteUrl ? `Remote URL: ${remoteUrl}` : `Command: ${command}`;
setModalMessage(
`Are you sure you want to install the ${extName} extension?\n\nCommand: ${command}`
`Are you sure you want to install the ${extName} extension?\n\n${messageDetails}`
);
setModalVisible(true);
} catch (error) {
@@ -3,6 +3,75 @@ import { toastService } from '../../../toasts';
import { activateExtension } from './extension-manager';
import { DEFAULT_EXTENSION_TIMEOUT, nameToKey } from './utils';
/**
* Build an extension config for stdio from the deeplink URL
*/
function getStdioConfig(
cmd: string,
parsedUrl: URL,
name: string,
description: string,
timeout: number
) {
// Validate that the command is one of the allowed commands
const allowedCommands = ['jbang', 'npx', 'uvx', 'goosed'];
if (!allowedCommands.includes(cmd)) {
toastService.handleError(
'Invalid Command',
`Failed to install extension: Invalid command: ${cmd}. Only ${allowedCommands.join(', ')} are allowed.`,
{ shouldThrow: true }
);
}
// Check for security risk with npx -c command
const args = parsedUrl.searchParams.getAll('arg');
if (cmd === 'npx' && args.includes('-c')) {
toastService.handleError(
'Security Risk',
'Failed to install extension: npx with -c argument can lead to code injection',
{ shouldThrow: true }
);
}
const envList = parsedUrl.searchParams.getAll('env');
// Create the extension config
const config: ExtensionConfig = {
name: name,
type: 'stdio',
cmd: cmd,
description,
args: args,
envs:
envList.length > 0
? Object.fromEntries(
envList.map((env) => {
const [key] = env.split('=');
return [key, '']; // Initialize with empty string as value
})
)
: undefined,
timeout: timeout,
};
return config;
}
/**
* Build an extension config for SSE from the deeplink URL
*/
function getSseConfig(remoteUrl: string, name: string, description: string, timeout: number) {
const config: ExtensionConfig = {
name,
type: 'sse',
uri: remoteUrl,
description,
timeout: timeout,
};
return config;
}
/**
* Handles adding an extension from a deeplink URL
*/
@@ -39,56 +108,17 @@ export async function addExtensionFromDeepLink(
}
}
const cmd = parsedUrl.searchParams.get('cmd');
if (!cmd) {
toastService.handleError(
'Missing Command',
"Failed to install extension: Missing required 'cmd' parameter in the URL",
{ shouldThrow: true }
);
}
// Validate that the command is one of the allowed commands
const allowedCommands = ['jbang', 'npx', 'uvx', 'goosed'];
if (!allowedCommands.includes(cmd)) {
toastService.handleError(
'Invalid Command',
`Failed to install extension: Invalid command: ${cmd}. Only ${allowedCommands.join(', ')} are allowed.`,
{ shouldThrow: true }
);
}
// Check for security risk with npx -c command
const args = parsedUrl.searchParams.getAll('arg');
if (cmd === 'npx' && args.includes('-c')) {
toastService.handleError(
'Security Risk',
'Failed to install extension: npx with -c argument can lead to code injection',
{ shouldThrow: true }
);
}
const envList = parsedUrl.searchParams.getAll('env');
const name = parsedUrl.searchParams.get('name')!;
const timeout = parsedUrl.searchParams.get('timeout');
const parsedTimeout = parsedUrl.searchParams.get('timeout');
const timeout = parsedTimeout ? parseInt(parsedTimeout, 10) : DEFAULT_EXTENSION_TIMEOUT;
const description = parsedUrl.searchParams.get('description');
// Create the extension config
const config: ExtensionConfig = {
name: name,
type: 'stdio',
cmd: cmd,
args: args,
envs:
envList.length > 0
? Object.fromEntries(
envList.map((env) => {
const [key] = env.split('=');
return [key, '']; // Initialize with empty string as value
})
)
: undefined,
timeout: timeout ? parseInt(timeout, 10) : DEFAULT_EXTENSION_TIMEOUT,
};
const cmd = parsedUrl.searchParams.get('cmd');
const remoteUrl = parsedUrl.searchParams.get('url');
const config = remoteUrl
? getSseConfig(remoteUrl, name, description, timeout)
: getStdioConfig(cmd!, parsedUrl, name, description, timeout);
// Check if extension requires env vars and go to settings if so
if (config.envs && Object.keys(config.envs).length > 0) {
+67 -23
View File
@@ -295,11 +295,50 @@ export async function addExtensionFromDeepLink(
}
const cmd = parsedUrl.searchParams.get('cmd');
if (!cmd) {
handleError("Failed to install extension: Missing required 'cmd' parameter in the URL", true);
const remoteUrl = parsedUrl.searchParams.get('url');
if (!cmd && !remoteUrl) {
handleError(
"Failed to install extension: Missing required 'cmd' or 'url' parameter in the URL",
true
);
}
// Validate that the command is one of the allowed commands
const id = parsedUrl.searchParams.get('id');
const name = parsedUrl.searchParams.get('name');
const description = parsedUrl.searchParams.get('description');
const timeout = parsedUrl.searchParams.get('timeout');
// Create a ExtensionConfig from the URL parameters
// Parse timeout if provided, otherwise use default
const parsedTimeout = timeout ? parseInt(timeout, 10) : null;
const config: FullExtensionConfig = cmd
? getStdioConfig(cmd, parsedUrl, id, name, description, parsedTimeout)
: getSseConfig(remoteUrl, id, name, description, parsedTimeout);
// Store the extension config regardless of env vars status
storeExtensionConfig(config);
// Check if extension requires env vars and go to settings if so
if (envVarsRequired(config)) {
console.log('Environment variables required, redirecting to settings');
setView('settings', { extensionId: config.id, showEnvVars: true });
return;
}
// If no env vars are required, proceed with extending Goosed
await addExtension(config);
}
function getStdioConfig(
cmd: string,
parsedUrl: URL,
id: string,
name: string,
description: string,
parsedTimeout: number
) {
const allowedCommands = ['jbang', 'npx', 'uvx', 'goosed'];
if (!allowedCommands.includes(cmd)) {
handleError(
@@ -315,10 +354,6 @@ export async function addExtensionFromDeepLink(
}
const envList = parsedUrl.searchParams.getAll('env');
const id = parsedUrl.searchParams.get('id');
const name = parsedUrl.searchParams.get('name');
const description = parsedUrl.searchParams.get('description');
const timeout = parsedUrl.searchParams.get('timeout');
// split env based on delimiter to a map
const envs = envList.reduce(
@@ -330,10 +365,6 @@ export async function addExtensionFromDeepLink(
{} as Record<string, string>
);
// Create a ExtensionConfig from the URL parameters
// Parse timeout if provided, otherwise use default
const parsedTimeout = timeout ? parseInt(timeout, 10) : null;
const config: FullExtensionConfig = {
id,
name,
@@ -349,16 +380,29 @@ export async function addExtensionFromDeepLink(
: DEFAULT_EXTENSION_TIMEOUT,
};
// Store the extension config regardless of env vars status
storeExtensionConfig(config);
// Check if extension requires env vars and go to settings if so
if (envVarsRequired(config)) {
console.log('Environment variables required, redirecting to settings');
setView('settings', { extensionId: config.id, showEnvVars: true });
return;
}
// If no env vars are required, proceed with extending Goosed
await addExtension(config);
return config;
}
function getSseConfig(
remoteUrl: string,
id: string,
name: string,
description: string,
parsedTimeout: number
) {
const config: FullExtensionConfig = {
id,
name,
type: 'sse',
uri: remoteUrl,
description,
enabled: true,
env_keys: [],
timeout:
parsedTimeout !== null && !isNaN(parsedTimeout) && Number.isInteger(parsedTimeout)
? parsedTimeout
: DEFAULT_EXTENSION_TIMEOUT,
};
return config;
}