From ad4bd3e25f0ad4203b11e547335f9609933428dc Mon Sep 17 00:00:00 2001 From: chenjie Date: Tue, 2 Jun 2026 18:10:50 +0800 Subject: [PATCH 1/2] changed gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 344937d93..59fa2c7cc 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ tmp/ # Documentation docs/_build/ +docs/tmp site/ # Node.js (TUI) From 060e981e3bd424da914b88b107af02d9dd9132ff Mon Sep 17 00:00:00 2001 From: chenjie Date: Wed, 3 Jun 2026 13:47:53 +0800 Subject: [PATCH 2/2] fix(webui): avoid misrouting absolute paths as slash commands Reject slash command names containing path separators so absolute file paths are sent via prompt_async, and add focused tests to prevent regressions in parser and chat routing. Co-authored-by: Cursor --- .../components/common/CommandDropdown.test.ts | 17 +++++++++++ .../src/components/common/CommandDropdown.tsx | 13 ++++++--- .../src/components/common/SessionChat.test.ts | 28 ++++++++++++++++++- webui/src/components/common/SessionChat.tsx | 13 ++++++--- 4 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 webui/src/components/common/CommandDropdown.test.ts diff --git a/webui/src/components/common/CommandDropdown.test.ts b/webui/src/components/common/CommandDropdown.test.ts new file mode 100644 index 000000000..ab1b57a98 --- /dev/null +++ b/webui/src/components/common/CommandDropdown.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { parseSlashCommand } from './CommandDropdown'; + +describe('parseSlashCommand', () => { + it('parses normal slash commands', () => { + expect(parseSlashCommand('/help')).toEqual({ command: 'help', args: '' }); + expect(parseSlashCommand('/plan build the thing')).toEqual({ + command: 'plan', + args: 'build the thing', + }); + }); + + it('does not treat absolute filesystem paths as commands', () => { + expect(parseSlashCommand('/tmp/workspace/workflow.md')).toBeNull(); + expect(parseSlashCommand('/tmp/rex_integration_guide.md\n\nuse this')).toBeNull(); + }); +}); diff --git a/webui/src/components/common/CommandDropdown.tsx b/webui/src/components/common/CommandDropdown.tsx index 114996104..2138ce0fa 100644 --- a/webui/src/components/common/CommandDropdown.tsx +++ b/webui/src/components/common/CommandDropdown.tsx @@ -89,16 +89,21 @@ export default function CommandDropdown({ * 从输入文本中解析 slash 命令的名称和参数 * 例如 "/plan create a feature" → { command: "plan", args: "create a feature" } */ +export function isSlashCommandName(command: string): boolean { + return command.length > 0 && !/[\\/]/.test(command); +} + export function parseSlashCommand(text: string): { command: string; args: string } | null { const trimmed = text.trim(); if (!trimmed.startsWith('/')) return null; const withoutSlash = trimmed.slice(1); const spaceIndex = withoutSlash.indexOf(' '); - if (spaceIndex === -1) { - return { command: withoutSlash, args: '' }; + const command = spaceIndex === -1 ? withoutSlash : withoutSlash.slice(0, spaceIndex); + if (!isSlashCommandName(command)) { + return null; } return { - command: withoutSlash.slice(0, spaceIndex), - args: withoutSlash.slice(spaceIndex + 1).trim(), + command, + args: spaceIndex === -1 ? '' : withoutSlash.slice(spaceIndex + 1).trim(), }; } diff --git a/webui/src/components/common/SessionChat.test.ts b/webui/src/components/common/SessionChat.test.ts index e26218a32..ab9a8f6c7 100644 --- a/webui/src/components/common/SessionChat.test.ts +++ b/webui/src/components/common/SessionChat.test.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -461,6 +461,32 @@ describe('SessionChat agent mentions', () => { }); }); +describe('SessionChat slash command routing', () => { + it('sends absolute filesystem paths as normal prompts instead of slash commands', async () => { + render(React.createElement(SessionChat, { + sessionId: 'sess-1', + })); + + const text = '/tmp/stream_alert_denoise/rex_integration_guide.md\n\nuse this file'; + const textarea = screen.getByPlaceholderText('请输入消息') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: text } }); + fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }); + + await waitFor(() => { + expect(clientPostMock).toHaveBeenCalledWith( + '/api/session/sess-1/prompt_async', + expect.objectContaining({ + parts: [{ type: 'text', text }], + }), + ); + }); + expect(clientPostMock).not.toHaveBeenCalledWith( + '/api/session/sess-1/command', + expect.anything(), + ); + }); +}); + describe('truncateToolDisplayText', () => { it('returns short text unchanged', () => { expect(truncateToolDisplayText('bash')).toBe('bash'); diff --git a/webui/src/components/common/SessionChat.tsx b/webui/src/components/common/SessionChat.tsx index fe648e249..799f94e8f 100644 --- a/webui/src/components/common/SessionChat.tsx +++ b/webui/src/components/common/SessionChat.tsx @@ -23,7 +23,7 @@ import { useTranslation } from 'react-i18next'; import LoadingSpinner from './LoadingSpinner'; import { QuestionTool } from './QuestionTool'; import DelegateTaskCard, { isDelegateTool, shouldRenderDelegateTaskCard } from './DelegateTaskCard'; -import CommandDropdown, { parseSlashCommand } from './CommandDropdown'; +import CommandDropdown, { isSlashCommandName, parseSlashCommand } from './CommandDropdown'; import ImageLightbox from './ImageLightbox'; import { useSessionMessages } from '@/hooks/useSessions'; import { useSSE, type SSEConnectionStatus } from '@/hooks/useSSE'; @@ -2415,15 +2415,20 @@ export default function SessionChat({ const cursor = e.target.selectionStart ?? val.length; const mention = mentionAgents.length > 0 ? findMentionTrigger(val, cursor) : null; const trimmed = val.trimStart(); + const slashQuery = trimmed.startsWith('/') ? trimmed.slice(1) : ''; if (mention && !trimmed.startsWith('/')) { setMentionRange({ start: mention.start, end: mention.end }); setMentionQuery(mention.query); setSelectedMentionIndex(0); setShowCommandDropdown(false); - } else if (trimmed.startsWith('/') && !trimmed.includes(' ') && successfulAttachments.length === 0) { + } else if ( + trimmed.startsWith('/') && + !trimmed.includes(' ') && + (slashQuery === '' || isSlashCommandName(slashQuery)) && + successfulAttachments.length === 0 + ) { void loadCommandsIfNeeded(); - const q = trimmed.slice(1); - setCommandQuery(q); + setCommandQuery(slashQuery); setSelectedCommandIndex(0); setShowCommandDropdown(true); setMentionRange(null);