fix(windows): support path mentions and editor launch

This commit is contained in:
iliya 2026-04-25 19:12:11 +03:00
parent 75f9e6bcec
commit f2b7024226
9 changed files with 226 additions and 62 deletions

View file

@ -20,11 +20,20 @@ const logger = createLogger('HTTP:validation');
* Prevents path traversal attacks. * Prevents path traversal attacks.
*/ */
function isPathContained(fullPath: string, basePath: string): boolean { function isPathContained(fullPath: string, basePath: string): boolean {
const normalizedFull = path.normalize(fullPath); const normalizedFull = normalizeForContainment(fullPath);
const normalizedBase = path.normalize(basePath); const normalizedBase = normalizeForContainment(basePath);
return normalizedFull === normalizedBase || normalizedFull.startsWith(normalizedBase + path.sep); return normalizedFull === normalizedBase || normalizedFull.startsWith(normalizedBase + path.sep);
} }
function normalizeForContainment(value: string): string {
const resolved = path.resolve(value);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}
function resolveProjectPath(projectPath: string, requestedPath: string): string {
return path.isAbsolute(requestedPath) ? requestedPath : path.join(projectPath, requestedPath);
}
export function registerValidationRoutes(app: FastifyInstance): void { export function registerValidationRoutes(app: FastifyInstance): void {
// Validate path // Validate path
app.post<{ Body: { relativePath: string; projectPath: string } }>( app.post<{ Body: { relativePath: string; projectPath: string } }>(
@ -32,7 +41,7 @@ export function registerValidationRoutes(app: FastifyInstance): void {
async (request) => { async (request) => {
try { try {
const { relativePath, projectPath } = request.body; const { relativePath, projectPath } = request.body;
const fullPath = path.join(projectPath, relativePath); const fullPath = resolveProjectPath(projectPath, relativePath);
if (!isPathContained(fullPath, projectPath)) { if (!isPathContained(fullPath, projectPath)) {
logger.warn('validate-path blocked path traversal attempt:', relativePath); logger.warn('validate-path blocked path traversal attempt:', relativePath);
@ -57,7 +66,7 @@ export function registerValidationRoutes(app: FastifyInstance): void {
// Validate all mentions in parallel with async I/O // Validate all mentions in parallel with async I/O
const entries = await Promise.all( const entries = await Promise.all(
mentions.map(async (mention) => { mentions.map(async (mention) => {
const fullPath = path.join(projectPath, mention.value); const fullPath = resolveProjectPath(projectPath, mention.value);
if (!isPathContained(fullPath, projectPath)) { if (!isPathContained(fullPath, projectPath)) {
return [`@${mention.value}`, false] as const; return [`@${mention.value}`, false] as const;
} }

View file

@ -18,10 +18,11 @@
*/ */
import { syncTelemetryFlag } from '@main/sentry'; import { syncTelemetryFlag } from '@main/sentry';
import { quoteWindowsCmdArg } from '@main/utils/childProcess';
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder'; import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
import { getErrorMessage } from '@shared/utils/errorHandling'; import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { execFile } from 'child_process'; import { execFile, execFileSync, spawn } from 'child_process';
import { BrowserWindow, dialog, type IpcMain, type IpcMainInvokeEvent } from 'electron'; import { BrowserWindow, dialog, type IpcMain, type IpcMainInvokeEvent } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -57,6 +58,72 @@ let onClaudeRootPathUpdated: ((claudeRootPath: string | null) => Promise<void> |
null; null;
let onAgentLanguageUpdated: ((newLangCode: string) => Promise<void> | void) | null = null; let onAgentLanguageUpdated: ((newLangCode: string) => Promise<void> | void) | null = null;
function isPathLikeCommand(command: string): boolean {
return /[\\/]/.test(command) || /^[A-Za-z]:/.test(command);
}
function resolveWindowsEditorCommand(editor: string): string {
if (process.platform !== 'win32' || isPathLikeCommand(editor)) {
return editor;
}
try {
const whereExe = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'where.exe');
const output = execFileSync(whereExe, [editor], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
return output.trim().split(/\r?\n/)[0] || editor;
} catch {
return editor;
}
}
function needsWindowsShell(command: string): boolean {
if (process.platform !== 'win32') return false;
const extension = path.extname(command).toLowerCase();
return extension === '.cmd' || extension === '.bat';
}
function launchExternalEditor(editor: string, configPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const resolvedEditor = resolveWindowsEditorCommand(editor);
const launchOptions = {
detached: true,
stdio: 'ignore' as const,
windowsHide: true,
};
let child: ReturnType<typeof spawn>;
if (needsWindowsShell(resolvedEditor)) {
const command = [resolvedEditor, configPath].map(quoteWindowsCmdArg).join(' ');
// eslint-disable-next-line sonarjs/os-command -- Windows .cmd launchers require cmd.exe; editor path is resolved via where.exe and args are cmd-escaped.
child = spawn(command, {
...launchOptions,
shell: true,
});
} else {
child = spawn(resolvedEditor, [configPath], launchOptions);
}
let settled = false;
function settle(fn: () => void): void {
if (settled) return;
settled = true;
clearTimeout(timer);
fn();
}
const timer = setTimeout(() => {
child.unref();
settle(() => resolve());
}, 500);
child.on('error', (err) => {
settle(() => reject(err));
});
});
}
/** /**
* Initializes config handlers with callbacks that require app-level services. * Initializes config handlers with callbacks that require app-level services.
*/ */
@ -607,16 +674,7 @@ async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise<IpcResult
for (const editor of editors) { for (const editor of editors) {
try { try {
await new Promise<void>((resolve, reject) => { await launchExternalEditor(editor, configPath);
const child = execFile(editor, [configPath], { timeout: 5000 });
// If the process spawns successfully, resolve after a short delay
// (editors typically fork and the parent exits quickly)
const timer = setTimeout(() => resolve(), 500);
child.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
});
return { success: true }; return { success: true };
} catch { } catch {
// Editor not found, try next // Editor not found, try next

View file

@ -192,13 +192,17 @@ function quoteCmdChunk(chunk: string): string {
return `"${escaped}"`; return `"${escaped}"`;
} }
function quoteArg(arg: string): string { export function quoteWindowsCmdArg(arg: string): string {
if (/[^A-Za-z0-9_\-/.]/.test(arg)) { if (/[^A-Za-z0-9_\-/.]/.test(arg)) {
return arg.split('%').map(quoteCmdChunk).join('^%'); return arg.split('%').map(quoteCmdChunk).join('^%');
} }
return arg; return arg;
} }
function quoteArg(arg: string): string {
return quoteWindowsCmdArg(arg);
}
/** Env vars injected into every spawned Claude CLI process. */ /** Env vars injected into every spawned Claude CLI process. */
const CLI_ENV_DEFAULTS: Record<string, string> = { const CLI_ENV_DEFAULTS: Record<string, string> = {
CLAUDE_HOOK_JUDGE_MODE: 'true', CLAUDE_HOOK_JUDGE_MODE: 'true',

View file

@ -8,6 +8,7 @@ import { useTabUI } from '@renderer/hooks/useTabUI';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { extractFileReferenceTokens } from '@renderer/utils/groupTransformer';
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
@ -33,9 +34,6 @@ import type { UserGroup } from '@renderer/types/groups';
const logger = createLogger('Component:UserChatGroup'); const logger = createLogger('Component:UserChatGroup');
// Pattern for @paths only (file references)
const PATH_PATTERN = /@([^\s,)}\]]+)/g;
interface UserChatGroupProps { interface UserChatGroupProps {
userGroup: UserGroup; userGroup: UserGroup;
} }
@ -46,24 +44,22 @@ interface UserChatGroupProps {
*/ */
// eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types // eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types
function highlightTextNode(text: string, validatedPaths: Record<string, boolean>): React.ReactNode { function highlightTextNode(text: string, validatedPaths: Record<string, boolean>): React.ReactNode {
const pathPattern = /@[^\s,)}\]]+/g; const pathReferences = extractFileReferenceTokens(text);
const parts: React.ReactNode[] = []; const parts: React.ReactNode[] = [];
let lastIndex = 0; let lastIndex = 0;
let match;
pathPattern.lastIndex = 0; for (const reference of pathReferences) {
while ((match = pathPattern.exec(text)) !== null) { if (reference.startIndex > lastIndex) {
if (match.index > lastIndex) { parts.push(text.slice(lastIndex, reference.startIndex));
parts.push(text.slice(lastIndex, match.index));
} }
const fullMatch = match[0]; const fullMatch = reference.raw;
const isValid = validatedPaths[fullMatch] === true; const isValid = validatedPaths[fullMatch] === true;
if (isValid) { if (isValid) {
parts.push( parts.push(
<span <span
key={match.index} key={reference.startIndex}
style={{ style={{
backgroundColor: 'var(--chat-user-tag-bg)', backgroundColor: 'var(--chat-user-tag-bg)',
color: 'var(--chat-user-tag-text)', color: 'var(--chat-user-tag-text)',
@ -81,7 +77,7 @@ function highlightTextNode(text: string, validatedPaths: Record<string, boolean>
parts.push(fullMatch); parts.push(fullMatch);
} }
lastIndex = match.index + fullMatch.length; lastIndex = reference.endIndex;
} }
if (lastIndex < text.length) { if (lastIndex < text.length) {
@ -456,13 +452,10 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
// Extract @path mentions from text // Extract @path mentions from text
const pathMentions = useMemo(() => { const pathMentions = useMemo(() => {
if (!textContent) return []; if (!textContent) return [];
const result: { value: string; raw: string }[] = []; return extractFileReferenceTokens(textContent).map((reference) => ({
const pathPattern = new RegExp(PATH_PATTERN.source, PATH_PATTERN.flags); value: reference.path,
let match; raw: reference.raw,
while ((match = pathPattern.exec(textContent)) !== null) { }));
result.push({ value: match[1], raw: match[0] });
}
return result;
}, [textContent]); }, [textContent]);
// Validate @path mentions via IPC // Validate @path mentions via IPC
@ -475,7 +468,11 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
const toValidate = pathMentions.map((m) => ({ type: 'path' as const, value: m.value })); const toValidate = pathMentions.map((m) => ({ type: 'path' as const, value: m.value }));
const results = await api.validateMentions(toValidate, projectPath); const results = await api.validateMentions(toValidate, projectPath);
if (isCurrent) { if (isCurrent) {
setValidatedPaths(results); setValidatedPaths(
Object.fromEntries(
pathMentions.map((mention) => [mention.raw, results[`@${mention.value}`] === true])
)
);
} }
} catch (err) { } catch (err) {
logger.error('Path validation failed:', err); logger.error('Path validation failed:', err);

View file

@ -19,6 +19,7 @@ import type { QuickOpenFile } from '@shared/types/editor';
const MAX_FILE_SUGGESTIONS = 8; const MAX_FILE_SUGGESTIONS = 8;
const MAX_FOLDER_SUGGESTIONS = 5; const MAX_FOLDER_SUGGESTIONS = 5;
const MENTION_PATH_QUOTE_NEEDED = /[\s,)}\]"']/;
export interface UseFileSuggestionsResult { export interface UseFileSuggestionsResult {
suggestions: MentionSuggestion[]; suggestions: MentionSuggestion[];
@ -35,6 +36,19 @@ interface DerivedFolder {
absolutePath: string; absolutePath: string;
} }
export function formatFileMentionPath(relativePath: string): string {
if (!MENTION_PATH_QUOTE_NEEDED.test(relativePath)) {
return relativePath;
}
if (!relativePath.includes('"')) {
return `"${relativePath}"`;
}
if (!relativePath.includes("'")) {
return `'${relativePath}'`;
}
return `"${relativePath.replace(/"/g, '')}"`;
}
/** /**
* Extracts unique directories from a list of file paths. * Extracts unique directories from a list of file paths.
* Returns directories sorted by depth (shallower first), then alphabetically. * Returns directories sorted by depth (shallower first), then alphabetically.
@ -94,6 +108,7 @@ export function filterFileSuggestions(files: QuickOpenFile[], query: string): Me
type: 'file', type: 'file',
filePath: f.path, filePath: f.path,
relativePath: f.relativePath, relativePath: f.relativePath,
insertText: formatFileMentionPath(f.relativePath),
}); });
} }
} }
@ -127,6 +142,7 @@ export function filterFolderSuggestions(
type: 'folder', type: 'folder',
filePath: f.absolutePath, filePath: f.absolutePath,
relativePath: f.relativePath, relativePath: f.relativePath,
insertText: formatFileMentionPath(f.relativePath),
}); });
} }
} }

View file

@ -353,17 +353,21 @@ const KNOWN_DIRS = new Set([
'node_modules', 'node_modules',
]); ]);
/** export type FileReferenceToken = FileReference & {
* Simple pattern for detecting @ mentions that could be file paths. startIndex: number;
* The filtering logic in extractFileReferences determines validity. endIndex: number;
*/ };
const FILE_REF_PATTERN = /@([~a-zA-Z0-9._/-]+)/g;
const UNQUOTED_FILE_REF_STOP = /[\s,)}\]]/;
/** /**
* Checks if a path looks like a valid file reference. * Checks if a path looks like a valid file reference.
* Must start with known dir, contain /, or start with ./ or ../ * Must start with known dir, contain /, or start with ./ or ../
*/ */
function isValidFileRef(path: string): boolean { function isValidFileRef(path: string): boolean {
if (/^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\')) {
return true;
}
// Check for relative path indicators // Check for relative path indicators
if (isRelativePath(path)) { if (isRelativePath(path)) {
return true; return true;
@ -385,6 +389,58 @@ function isValidFileRef(path: string): boolean {
return false; return false;
} }
function readFileRefAt(text: string, atIndex: number): FileReferenceToken | null {
const valueStart = atIndex + 1;
const firstChar = text[valueStart];
if (!firstChar) return null;
let path = '';
let endIndex = valueStart;
if (firstChar === '"' || firstChar === "'") {
const quote = firstChar;
const quotedStart = valueStart + 1;
const quotedEnd = text.indexOf(quote, quotedStart);
if (quotedEnd < 0) return null;
path = text.slice(quotedStart, quotedEnd);
endIndex = quotedEnd + 1;
} else {
while (endIndex < text.length && !UNQUOTED_FILE_REF_STOP.test(text[endIndex])) {
endIndex += 1;
}
path = text.slice(valueStart, endIndex);
}
if (!path || !isValidFileRef(path)) return null;
return {
path,
raw: text.slice(atIndex, endIndex),
startIndex: atIndex,
endIndex,
};
}
export function extractFileReferenceTokens(text: string): FileReferenceToken[] {
if (!text) return [];
const references: FileReferenceToken[] = [];
let index = 0;
while (index < text.length) {
const atIndex = text.indexOf('@', index);
if (atIndex < 0) break;
const reference = readFileRefAt(text, atIndex);
if (reference) {
references.push(reference);
index = reference.endIndex;
} else {
index = atIndex + 1;
}
}
return references;
}
/** /**
* Extracts file references (@file.ts) from text. * Extracts file references (@file.ts) from text.
* *
@ -392,25 +448,7 @@ function isValidFileRef(path: string): boolean {
* @returns Array of FileReference objects * @returns Array of FileReference objects
*/ */
export function extractFileReferences(text: string): FileReference[] { export function extractFileReferences(text: string): FileReference[] {
if (!text) return []; return extractFileReferenceTokens(text).map(({ path, raw }) => ({ path, raw }));
const references: FileReference[] = [];
// Reset regex state before use
FILE_REF_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = FILE_REF_PATTERN.exec(text)) !== null) {
const [fullMatch, path] = match;
// Only include if it looks like a valid file reference
if (isValidFileRef(path)) {
references.push({
path,
raw: fullMatch,
});
}
}
return references;
} }
// ============================================================================= // =============================================================================

View file

@ -19,7 +19,12 @@ vi.mock('child_process', async (importOriginal) => {
// Import after the mock call so that the mocked module is returned. // Import after the mock call so that the mocked module is returned.
import * as child from 'child_process'; import * as child from 'child_process';
import { execCli, killTrackedCliProcesses, spawnCli } from '@main/utils/childProcess'; import {
execCli,
killTrackedCliProcesses,
quoteWindowsCmdArg,
spawnCli,
} from '@main/utils/childProcess';
type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void; type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void;
@ -68,6 +73,15 @@ describe('cli child process helpers', () => {
setPlatform(originalPlatform); setPlatform(originalPlatform);
}); });
describe('quoteWindowsCmdArg', () => {
it('keeps percent signs literal in cmd.exe command strings', () => {
const quoted = quoteWindowsCmdArg('C:\\Users\\Alice\\a%PATH%b.txt');
expect(quoted).toContain('"C:\\Users\\Alice\\a"^%"PATH"^%"b.txt"');
expect(quoted).not.toContain('%PATH%');
expect(quoted).not.toContain('%%PATH%%');
});
});
describe('spawnCli', () => { describe('spawnCli', () => {
it('calls spawn directly when path is ascii on windows', () => { it('calls spawn directly when path is ascii on windows', () => {
setPlatform('win32'); setPlatform('win32');

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { filterFileSuggestions } from '@renderer/hooks/useFileSuggestions'; import { filterFileSuggestions, formatFileMentionPath } from '@renderer/hooks/useFileSuggestions';
import type { QuickOpenFile } from '@shared/types/editor'; import type { QuickOpenFile } from '@shared/types/editor';
@ -43,6 +43,7 @@ describe('filterFileSuggestions', () => {
expect(results[0].type).toBe('file'); expect(results[0].type).toBe('file');
expect(results[0].filePath).toBe('/project/src/test.ts'); expect(results[0].filePath).toBe('/project/src/test.ts');
expect(results[0].relativePath).toBe('src/test.ts'); expect(results[0].relativePath).toBe('src/test.ts');
expect(results[0].insertText).toBe('src/test.ts');
}); });
it('filters by relative path', () => { it('filters by relative path', () => {
@ -94,4 +95,10 @@ describe('filterFileSuggestions', () => {
const results = filterFileSuggestions(FILES, '.ts'); const results = filterFileSuggestions(FILES, '.ts');
expect(results[0].name).toBe('index.ts'); expect(results[0].name).toBe('index.ts');
}); });
it('quotes inserted paths that contain spaces', () => {
expect(formatFileMentionPath('src/My Component/App.tsx')).toBe(
'"src/My Component/App.tsx"'
);
});
}); });

View file

@ -82,6 +82,27 @@ describe('extractFileReferences', () => {
expect(refs).toHaveLength(1); expect(refs).toHaveLength(1);
expect(refs[0].path).toBe('~/projects/foo'); expect(refs[0].path).toBe('~/projects/foo');
}); });
it('accepts Windows backslash paths', () => {
const refs = extractFileReferences('Check @src\\components\\App.tsx');
expect(refs).toHaveLength(1);
expect(refs[0].path).toBe('src\\components\\App.tsx');
});
it('accepts Windows drive absolute paths', () => {
const refs = extractFileReferences('Open @C:\\Users\\Alice\\project\\src\\App.tsx');
expect(refs).toHaveLength(1);
expect(refs[0].path).toBe('C:\\Users\\Alice\\project\\src\\App.tsx');
});
it('accepts quoted paths with spaces', () => {
const refs = extractFileReferences('Open @"src/My Component/App.tsx" now');
expect(refs).toHaveLength(1);
expect(refs[0]).toEqual({
path: 'src/My Component/App.tsx',
raw: '@"src/My Component/App.tsx"',
});
});
}); });
describe('rejects npm scoped packages', () => { describe('rejects npm scoped packages', () => {