fix: avoid shell-escaping special characters except quotes (#7242)
This commit is contained in:
@@ -2,6 +2,7 @@ import ExtensionItem from './ExtensionItem';
|
|||||||
import builtInExtensionsData from '../../../../built-in-extensions.json';
|
import builtInExtensionsData from '../../../../built-in-extensions.json';
|
||||||
import { ExtensionConfig } from '../../../../api';
|
import { ExtensionConfig } from '../../../../api';
|
||||||
import { FixedExtensionEntry } from '../../../ConfigContext';
|
import { FixedExtensionEntry } from '../../../ConfigContext';
|
||||||
|
import { combineCmdAndArgs } from '../utils';
|
||||||
|
|
||||||
interface ExtensionListProps {
|
interface ExtensionListProps {
|
||||||
extensions: FixedExtensionEntry[];
|
extensions: FixedExtensionEntry[];
|
||||||
@@ -137,7 +138,7 @@ export function getSubtitle(config: ExtensionConfig) {
|
|||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
description: config.description || null,
|
description: config.description || null,
|
||||||
command: 'cmd' in config ? [config.cmd, ...config.args].join(' ') : null,
|
command: 'cmd' in config ? combineCmdAndArgs(config.cmd, config.args) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,85 @@ describe('Extension Utils', () => {
|
|||||||
headers: [],
|
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', () => {
|
describe('createExtensionConfig', () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FixedExtensionEntry } from '../../ConfigContext';
|
import type { FixedExtensionEntry } from '../../ConfigContext';
|
||||||
import type { ExtensionConfig } from '../../../api/types.gen';
|
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
|
// Default extension timeout in seconds
|
||||||
// TODO: keep in sync with rust better
|
// TODO: keep in sync with rust better
|
||||||
@@ -100,11 +100,11 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo
|
|||||||
description: extension.description || '',
|
description: extension.description || '',
|
||||||
type:
|
type:
|
||||||
extension.type === 'frontend' ||
|
extension.type === 'frontend' ||
|
||||||
extension.type === 'inline_python' ||
|
extension.type === 'inline_python' ||
|
||||||
extension.type === 'platform'
|
extension.type === 'platform'
|
||||||
? 'stdio'
|
? 'stdio'
|
||||||
: extension.type,
|
: extension.type,
|
||||||
cmd: extension.type === 'stdio' ? quoteShell([extension.cmd, ...extension.args]) : undefined,
|
cmd: extension.type === 'stdio' ? combineCmdAndArgs(extension.cmd, extension.args) : undefined,
|
||||||
endpoint:
|
endpoint:
|
||||||
extension.type === 'streamable_http' || extension.type === 'sse'
|
extension.type === 'streamable_http' || extension.type === 'sse'
|
||||||
? (extension.uri ?? undefined)
|
? (extension.uri ?? undefined)
|
||||||
@@ -187,7 +187,13 @@ export function splitCmdAndArgs(str: string): { cmd: string; args: string[] } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function combineCmdAndArgs(cmd: string, args: string[]): 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 {
|
export function extractCommand(link: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user