diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx index d42b94baa055..288c1c5f56a5 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx @@ -2,6 +2,7 @@ import ExtensionItem from './ExtensionItem'; import builtInExtensionsData from '../../../../built-in-extensions.json'; import { ExtensionConfig } from '../../../../api'; import { FixedExtensionEntry } from '../../../ConfigContext'; +import { combineCmdAndArgs } from '../utils'; interface ExtensionListProps { extensions: FixedExtensionEntry[]; @@ -137,7 +138,7 @@ export function getSubtitle(config: ExtensionConfig) { default: return { description: config.description || null, - command: 'cmd' in config ? [config.cmd, ...config.args].join(' ') : null, + command: 'cmd' in config ? combineCmdAndArgs(config.cmd, config.args) : null, }; } } diff --git a/ui/desktop/src/components/settings/extensions/utils.test.ts b/ui/desktop/src/components/settings/extensions/utils.test.ts index 8888e6833e73..ab886aa173e8 100644 --- a/ui/desktop/src/components/settings/extensions/utils.test.ts +++ b/ui/desktop/src/components/settings/extensions/utils.test.ts @@ -156,6 +156,85 @@ describe('Extension Utils', () => { headers: [], }); }); + + it('should not escape @ in command args', () => { + const extension: FixedExtensionEntry = { + type: 'stdio', + name: 'context7', + description: 'Context7 MCP', + cmd: 'npx', + args: ['-y', '@upstash/context7-mcp'], + enabled: true, + }; + + const formData = extensionToFormData(extension); + expect(formData.cmd).toBe('npx -y @upstash/context7-mcp'); + }); + + it('should quote args with spaces', () => { + const extension: FixedExtensionEntry = { + type: 'stdio', + name: 'java-app', + description: 'Java app', + cmd: '/Applications/IntelliJ IDEA.app/Contents/jbr/Contents/Home/bin/java', + args: ['-classpath', '/path/with spaces/lib.jar', 'Main'], + enabled: true, + }; + + const formData = extensionToFormData(extension); + expect(formData.cmd).toBe( + '"/Applications/IntelliJ IDEA.app/Contents/jbr/Contents/Home/bin/java" -classpath "/path/with spaces/lib.jar" Main' + ); + }); + + it('should roundtrip command with @ through form data', () => { + const extension: FixedExtensionEntry = { + type: 'stdio', + name: 'context7', + description: 'Context7 MCP', + cmd: 'npx', + args: ['-y', '@upstash/context7-mcp'], + enabled: true, + }; + + const formData = extensionToFormData(extension); + const { cmd, args } = splitCmdAndArgs(formData.cmd || ''); + expect(cmd).toBe('npx'); + expect(args).toEqual(['-y', '@upstash/context7-mcp']); + }); + + it('should roundtrip command with spaces through form data', () => { + const extension: FixedExtensionEntry = { + type: 'stdio', + name: 'java-app', + description: 'Java app', + cmd: '/Applications/IntelliJ IDEA.app/Contents/jbr/Contents/Home/bin/java', + args: ['-classpath', '/path/with spaces/lib.jar', 'Main'], + enabled: true, + }; + + const formData = extensionToFormData(extension); + const { cmd, args } = splitCmdAndArgs(formData.cmd || ''); + expect(cmd).toBe('/Applications/IntelliJ IDEA.app/Contents/jbr/Contents/Home/bin/java'); + expect(args).toEqual(['-classpath', '/path/with spaces/lib.jar', 'Main']); + }); + + it('should roundtrip args with double quotes and spaces through form data', () => { + const extension: FixedExtensionEntry = { + type: 'stdio', + name: 'test', + description: 'test', + cmd: 'node', + args: ['/My "Project"/bin/run'], + enabled: true, + }; + + const formData = extensionToFormData(extension); + expect(formData.cmd).toBe('node \'/My "Project"/bin/run\''); + const { cmd, args } = splitCmdAndArgs(formData.cmd || ''); + expect(cmd).toBe('node'); + expect(args).toEqual(['/My "Project"/bin/run']); + }); }); describe('createExtensionConfig', () => { diff --git a/ui/desktop/src/components/settings/extensions/utils.ts b/ui/desktop/src/components/settings/extensions/utils.ts index 3ea8bb04cff0..00e29c1fa506 100644 --- a/ui/desktop/src/components/settings/extensions/utils.ts +++ b/ui/desktop/src/components/settings/extensions/utils.ts @@ -1,6 +1,6 @@ import type { FixedExtensionEntry } from '../../ConfigContext'; import type { ExtensionConfig } from '../../../api/types.gen'; -import { parse as parseShellQuote, quote as quoteShell } from 'shell-quote'; +import { parse as parseShellQuote } from 'shell-quote'; // Default extension timeout in seconds // TODO: keep in sync with rust better @@ -100,11 +100,11 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo description: extension.description || '', type: extension.type === 'frontend' || - extension.type === 'inline_python' || - extension.type === 'platform' + extension.type === 'inline_python' || + extension.type === 'platform' ? 'stdio' : extension.type, - cmd: extension.type === 'stdio' ? quoteShell([extension.cmd, ...extension.args]) : undefined, + cmd: extension.type === 'stdio' ? combineCmdAndArgs(extension.cmd, extension.args) : undefined, endpoint: extension.type === 'streamable_http' || extension.type === 'sse' ? (extension.uri ?? undefined) @@ -187,7 +187,13 @@ export function splitCmdAndArgs(str: string): { cmd: string; args: string[] } { } export function combineCmdAndArgs(cmd: string, args: string[]): string { - return quoteShell([cmd, ...args]); + return [cmd, ...args] + .map((a) => { + if (!a.includes(' ')) return a; + if (a.includes('"')) return `'${a}'`; + return `"${a}"`; + }) + .join(' '); } export function extractCommand(link: string): string {