From 9815cd7e91eaf8204cbd9f4ef515ef5d4d04ba46 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 8 Jul 2026 10:55:41 +0800 Subject: [PATCH] fix(ISSUE-64): quote legacy install command versions Signed-off-by: dongmucat <1127093059@qq.com> --- .../features/skill/install-command.test.ts | 15 ++++-- web/src/features/skill/install-command.tsx | 20 +++++++- web/src/pages/skill-detail.test.tsx | 49 +++++++++++++++++++ 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/web/src/features/skill/install-command.test.ts b/web/src/features/skill/install-command.test.ts index f9eaa51c..804406a4 100644 --- a/web/src/features/skill/install-command.test.ts +++ b/web/src/features/skill/install-command.test.ts @@ -74,6 +74,15 @@ describe('install-command', () => { ) }) + it('shell-quotes historical supported versions that include spaces', () => { + expect(buildInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn', '1.0.0 beta')).toBe( + "npx clawhub install team-alpha--my-skill --registry https://skill.xfyun.cn --version '1.0.0 beta'", + ) + expect(buildSkillhubInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn', '1.0.0 beta')).toBe( + "npx @astron-team/skillhub@latest install my-skill --namespace team-alpha --registry https://skill.xfyun.cn --version '1.0.0 beta'", + ) + }) + it('builds a one-line SkillHub npx command for the global namespace', () => { expect(buildSkillhubInstallCommand('global', 'my-skill', 'https://skill.xfyun.cn')).toBe( 'npx @astron-team/skillhub@latest install my-skill --registry https://skill.xfyun.cn', @@ -92,12 +101,12 @@ describe('install-command', () => { ) }) - it('omits unsafe versions from install commands', () => { + it('shell-quotes potentially hostile versions instead of silently dropping them', () => { expect(buildInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn', '0.9.0 && whoami')).toBe( - 'npx clawhub install team-alpha--my-skill --registry https://skill.xfyun.cn', + "npx clawhub install team-alpha--my-skill --registry https://skill.xfyun.cn --version '0.9.0 && whoami'", ) expect(buildSkillhubInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn', '0.9.0 && whoami')).toBe( - 'npx @astron-team/skillhub@latest install my-skill --namespace team-alpha --registry https://skill.xfyun.cn', + "npx @astron-team/skillhub@latest install my-skill --namespace team-alpha --registry https://skill.xfyun.cn --version '0.9.0 && whoami'", ) }) diff --git a/web/src/features/skill/install-command.tsx b/web/src/features/skill/install-command.tsx index 5a6a4b5c..b0d8c479 100644 --- a/web/src/features/skill/install-command.tsx +++ b/web/src/features/skill/install-command.tsx @@ -12,6 +12,8 @@ interface InstallCommandProps { } const SAFE_VERSION_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._+-]*[A-Za-z0-9])?$/ +const CONTROL_CHARACTER_PATTERN = /\p{Cc}/u +const SHELL_SINGLE_QUOTE_ESCAPE = `'"'"'` export function buildInstallTarget(namespace: string, slug: string): string { return namespace === 'global' ? slug : `${namespace}--${slug}` @@ -31,11 +33,25 @@ export function getBaseUrl(): string { return `${window.location.protocol}//${window.location.host}` } +function escapeShellArgument(value: string): string | null { + if (!value || CONTROL_CHARACTER_PATTERN.test(value)) { + return null + } + if (SAFE_VERSION_PATTERN.test(value)) { + return value + } + return `'${value.replace(/'/g, SHELL_SINGLE_QUOTE_ESCAPE)}'` +} + function buildVersionedCommand(command: string, version?: string): string { - if (!version || !SAFE_VERSION_PATTERN.test(version)) { + if (!version) { return command } - return `${command} --version ${version}` + const escapedVersion = escapeShellArgument(version) + if (!escapedVersion) { + return command + } + return `${command} --version ${escapedVersion}` } export function buildInstallCommand(namespace: string, slug: string, baseUrl: string, version?: string): string { diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index 026d9865..fe2bb630 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -392,6 +392,55 @@ describe('SkillDetailPage', () => { expect(html).toContain('install:0.9.0') }) + it('passes historical published versions with spaces through to the install command', () => { + detailSearchState = { returnTo: '/dashboard/skills', version: '1.0.0 beta' } + useSkillVersionsMock.mockReturnValue({ + data: [ + { + id: 10, + version: '1.0.0', + status: 'PUBLISHED', + changelog: '', + fileCount: 1, + totalSize: 12, + publishedAt: '2026-03-20T00:00:00Z', + downloadAvailable: true, + }, + { + id: 9, + version: '1.0.0 beta', + status: 'PUBLISHED', + changelog: '', + fileCount: 1, + totalSize: 12, + publishedAt: '2026-03-19T00:00:00Z', + downloadAvailable: true, + }, + ], + }) + useSkillVersionDetailMock.mockImplementation((_namespace: string, _slug: string, version?: string) => ({ + data: version === '1.0.0 beta' + ? { + id: 9, + version: '1.0.0 beta', + status: 'PUBLISHED', + changelog: '', + fileCount: 1, + totalSize: 12, + publishedAt: '2026-03-19T00:00:00Z', + parsedMetadataJson: '{}', + manifestJson: '[]', + complianceMappings: [], + } + : undefined, + })) + + const html = renderToStaticMarkup() + + expect(useSkillVersionDetailMock).toHaveBeenCalledWith('global', 'demo-skill', '1.0.0 beta', true) + expect(html).toContain('install:1.0.0 beta') + }) + it('shows the label management panel for a user who can manage the skill lifecycle', () => { useSkillDetailMock.mockReturnValue({ data: createSkill({