From 645ac4573e00b0cc9db3f795093b31ab93873e2e Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 25 Apr 2026 20:23:03 +0300 Subject: [PATCH 1/3] fix(windows): align session paths and validators --- src/main/http/validation.ts | 7 +- src/main/ipc/guards.ts | 22 +++++++ src/main/ipc/validation.ts | 24 +++++-- .../services/discovery/ProjectPathResolver.ts | 35 ++++++---- src/main/services/discovery/ProjectScanner.ts | 28 ++++++-- .../services/parsing/GitIdentityResolver.ts | 16 +++-- src/main/services/team/FileContentResolver.ts | 7 +- .../services/team/TeamLogSourceTracker.ts | 11 +--- src/main/utils/pathDecoder.ts | 64 +++++++++++++++++-- src/main/utils/pathValidation.ts | 57 ++++++++++++++++- test/main/ipc/guards.test.ts | 7 ++ .../discovery/ProjectScanner.cwdSplit.test.ts | 39 +++++++++++ test/main/utils/pathDecoder.test.ts | 23 +++++++ test/main/utils/pathValidation.test.ts | 38 +++++++++++ 14 files changed, 324 insertions(+), 54 deletions(-) diff --git a/src/main/http/validation.ts b/src/main/http/validation.ts index 9b15c606..90c09147 100644 --- a/src/main/http/validation.ts +++ b/src/main/http/validation.ts @@ -22,7 +22,8 @@ const logger = createLogger('HTTP:validation'); function isPathContained(fullPath: string, basePath: string): boolean { const normalizedFull = normalizeForContainment(fullPath); const normalizedBase = normalizeForContainment(basePath); - return normalizedFull === normalizedBase || normalizedFull.startsWith(normalizedBase + path.sep); + const relative = path.relative(normalizedBase, normalizedFull); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); } function normalizeForContainment(value: string): string { @@ -31,7 +32,9 @@ function normalizeForContainment(value: string): string { } function resolveProjectPath(projectPath: string, requestedPath: string): string { - return path.isAbsolute(requestedPath) ? requestedPath : path.join(projectPath, requestedPath); + return path.isAbsolute(requestedPath) + ? path.resolve(path.normalize(requestedPath)) + : path.resolve(projectPath, requestedPath); } export function registerValidationRoutes(app: FastifyInstance): void { diff --git a/src/main/ipc/guards.ts b/src/main/ipc/guards.ts index f9d7d6cc..074a335f 100644 --- a/src/main/ipc/guards.ts +++ b/src/main/ipc/guards.ts @@ -7,6 +7,7 @@ */ import { isValidProjectId } from '@main/utils/pathDecoder'; +import { isWindowsReservedFileName } from '@main/utils/pathValidation'; const SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; const SUBAGENT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; @@ -51,6 +52,12 @@ function validateString( return { valid: true, value: trimmed }; } +function rejectWindowsReserved(value: string, fieldName: string): ValidationResult | null { + return isWindowsReservedFileName(value) + ? { valid: false, error: `${fieldName} is reserved on Windows` } + : null; +} + export function validateProjectId(projectId: unknown): ValidationResult { const basic = validateString(projectId, 'projectId'); if (!basic.valid) { @@ -126,6 +133,11 @@ export function validateTeamName(teamName: unknown): ValidationResult { return { valid: false, error: 'teamName contains invalid characters' }; } + const reserved = rejectWindowsReserved(basic.value!, 'teamName'); + if (reserved) { + return reserved; + } + return { valid: true, value: basic.value }; } @@ -139,6 +151,11 @@ export function validateTaskId(taskId: unknown): ValidationResult { return { valid: false, error: 'taskId contains invalid characters' }; } + const reserved = rejectWindowsReserved(basic.value!, 'taskId'); + if (reserved) { + return reserved; + } + return { valid: true, value: basic.value }; } @@ -152,6 +169,11 @@ export function validateMemberName(memberName: unknown): ValidationResult { try { - const fullPath = path.join(projectPath, relativePath); + const fullPath = resolveProjectPath(projectPath, relativePath); // Security: Ensure path doesn't escape project directory if (!isPathContained(fullPath, projectPath)) { @@ -100,7 +110,7 @@ async function handleValidateMentions( // (was sequential sync existsSync — blocked main thread per mention) const entries = await Promise.all( mentions.map(async (mention) => { - const fullPath = path.join(projectPath, mention.value); + const fullPath = resolveProjectPath(projectPath, mention.value); // Security: Skip paths that escape project directory if (!isPathContained(fullPath, projectPath)) { diff --git a/src/main/services/discovery/ProjectPathResolver.ts b/src/main/services/discovery/ProjectPathResolver.ts index e8ba6801..161b56ba 100644 --- a/src/main/services/discovery/ProjectPathResolver.ts +++ b/src/main/services/discovery/ProjectPathResolver.ts @@ -11,7 +11,12 @@ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; import { extractCwd } from '@main/utils/jsonl'; -import { decodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; +import { + decodePath, + extractBaseDir, + getProjectDirNameCandidates, + getProjectsBasePath, +} from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import * as path from 'path'; @@ -109,20 +114,24 @@ export class ProjectPathResolver { } private async listSessionPaths(projectId: string): Promise { - const projectDir = path.join(this.projectsDir, extractBaseDir(projectId)); - if (!(await this.fsProvider.exists(projectDir))) { - return []; + for (const dirName of getProjectDirNameCandidates(projectId)) { + const projectDir = path.join(this.projectsDir, dirName); + if (!(await this.fsProvider.exists(projectDir))) { + continue; + } + + try { + const entries = await this.fsProvider.readdir(projectDir); + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')) + .map((entry) => path.join(projectDir, entry.name)); + } catch (error) { + logger.error(`Failed to read session files for ${projectId}:`, error); + return []; + } } - try { - const entries = await this.fsProvider.readdir(projectDir); - return entries - .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')) - .map((entry) => path.join(projectDir, entry.name)); - } catch (error) { - logger.error(`Failed to read session files for ${projectId}:`, error); - return []; - } + return []; } } diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 4fe892a1..38337f23 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -47,6 +47,7 @@ import { extractBaseDir, extractProjectName, extractSessionId, + getProjectDirNameCandidates, getProjectsBasePath, getTodosBasePath, isValidEncodedPath, @@ -76,6 +77,10 @@ const SEARCH_PROJECT_CACHE_TTL_MS = 30_000; // for lookups and navigation; a small cap preserves that behavior without huge payloads. const MAX_SESSION_IDS_EXPORTED = 200; +function splitPathSegments(value: string): string[] { + return value.split(/[/\\]+/).filter(Boolean); +} + /** * Fast, zero-I/O worktree detection based on path patterns only. * Used by scanWithWorktreeGrouping to provide accurate worktree metadata @@ -85,7 +90,7 @@ function detectWorktreeFromPath(projectPath: string): { isWorktree: boolean; source: WorktreeSource; } { - const parts = projectPath.split(path.sep).filter(Boolean); + const parts = splitPathSegments(projectPath); if (parts.includes(VIBE_KANBAN_DIR) && parts.includes(WORKTREES_DIR)) { return { isWorktree: true, source: 'vibe-kanban' }; @@ -601,12 +606,12 @@ export class ProjectScanner { * Handles composite IDs by scanning the base directory and finding the matching subproject. */ async getProject(projectId: string): Promise { - const baseDir = extractBaseDir(projectId); - const projectPath = path.join(this.projectsDir, baseDir); + const projectPath = await this.resolveProjectStorageDir(projectId); - if (!(await this.fsProvider.exists(projectPath))) { + if (!projectPath) { return null; } + const baseDir = path.basename(projectPath); // For composite IDs, scan and find the matching subproject if (subprojectRegistry.isComposite(projectId)) { @@ -1214,11 +1219,10 @@ export class ProjectScanner { */ async listSessionFiles(projectId: string): Promise { try { - const baseDir = extractBaseDir(projectId); - const projectPath = path.join(this.projectsDir, baseDir); + const projectPath = await this.resolveProjectStorageDir(projectId); const sessionFilter = await this.getSessionFilterForProject(projectId); - if (!(await this.fsProvider.exists(projectPath))) { + if (!projectPath) { return []; } @@ -1237,6 +1241,16 @@ export class ProjectScanner { } } + private async resolveProjectStorageDir(projectId: string): Promise { + for (const dirName of getProjectDirNameCandidates(projectId)) { + const projectPath = path.join(this.projectsDir, dirName); + if (await this.fsProvider.exists(projectPath)) { + return projectPath; + } + } + return null; + } + /** * Returns the session filter set for a project. * In local mode, composite IDs are refreshed from disk first so newly created diff --git a/src/main/services/parsing/GitIdentityResolver.ts b/src/main/services/parsing/GitIdentityResolver.ts index 3200c1af..09fdfd48 100644 --- a/src/main/services/parsing/GitIdentityResolver.ts +++ b/src/main/services/parsing/GitIdentityResolver.ts @@ -51,6 +51,10 @@ async function fileExists(filePath: string): Promise { } } +function splitPathSegments(value: string): string[] { + return value.split(/[/\\]+/).filter(Boolean); +} + class GitIdentityResolver { private identityCache = new Map>(); private branchCache = new Map>(); @@ -195,7 +199,7 @@ class GitIdentityResolver { * - Default: last path component */ private extractRepoNameFromPath(projectPath: string): string | null { - const parts = projectPath.split(path.sep).filter(Boolean); + const parts = splitPathSegments(projectPath); if (parts.length === 0) { return null; @@ -271,7 +275,7 @@ class GitIdentityResolver { */ async isWorktree(projectPath: string): Promise { // First, try path-based heuristics (works for deleted worktrees) - const parts = projectPath.split(path.sep).filter(Boolean); + const parts = splitPathSegments(projectPath); // Check for known worktree patterns - these are ALWAYS worktrees if (parts.includes(CURSOR_DIR) && parts.includes(WORKTREES_DIR)) { @@ -326,7 +330,7 @@ class GitIdentityResolver { private extractMainGitDir(worktreeGitDir: string): string { // worktreeGitDir is typically: /path/to/main/.git/worktrees/ // We need to go up two levels to get to .git - const parts = worktreeGitDir.split(path.sep); + const parts = splitPathSegments(worktreeGitDir); const worktreesIndex = parts.lastIndexOf(WORKTREES_DIR); if (worktreesIndex > 0) { @@ -551,7 +555,7 @@ class GitIdentityResolver { * @returns WorktreeSource identifier */ async detectWorktreeSource(projectPath: string): Promise { - const parts = projectPath.split(path.sep).filter(Boolean); + const parts = splitPathSegments(projectPath); // Pattern: vibe-kanban // /tmp/vibe-kanban/worktrees/{issue-branch}/{repo} @@ -626,7 +630,7 @@ class GitIdentityResolver { branch: string | null, isMainWorktree: boolean ): Promise { - const parts = projectPath.split(path.sep).filter(Boolean); + const parts = splitPathSegments(projectPath); switch (source) { case 'vibe-kanban': { @@ -754,7 +758,7 @@ class GitIdentityResolver { if (!match) return null; // gitdir: /main/.git/worktrees/my-worktree-name - const gitdirParts = match[1].trim().split(path.sep); + const gitdirParts = splitPathSegments(match[1].trim()); const worktreesIdx = gitdirParts.lastIndexOf(WORKTREES_DIR); if (worktreesIdx >= 0 && gitdirParts[worktreesIdx + 1]) { return gitdirParts[worktreesIdx + 1]; diff --git a/src/main/services/team/FileContentResolver.ts b/src/main/services/team/FileContentResolver.ts index ebed1d6c..9b3e651d 100644 --- a/src/main/services/team/FileContentResolver.ts +++ b/src/main/services/team/FileContentResolver.ts @@ -378,7 +378,10 @@ export class FileContentResolver { * For subagents, sessionId = the parent directory's parent name. */ private extractSessionId(logPath: string): string | null { - const parts = path.normalize(logPath).split(path.sep).filter(Boolean); + const parts = path + .normalize(logPath) + .split(/[/\\]+/) + .filter(Boolean); // Check if it's a subagent path: .../{sessionId}/subagents/agent-xxx.jsonl const subagentsIdx = parts.indexOf('subagents'); @@ -613,7 +616,7 @@ export class FileContentResolver { private getDisplayRelativePath(filePath: string, segmentCount: number): string { const normalized = path.normalize(filePath); - const parts = normalized.split(path.sep).filter(Boolean); + const parts = normalized.split(/[/\\]+/).filter(Boolean); return parts.slice(-segmentCount).join('/'); } diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index 85f248b0..bee27e61 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -52,16 +52,13 @@ function isOpaqueSafeTaskIdSegment(segment: string): boolean { return /^task-id-[0-9a-f]{32}$/.test(segment); } -export function shouldIgnoreLogSourceWatcherPath( - projectDir: string, - watchedPath: string -): boolean { +export function shouldIgnoreLogSourceWatcherPath(projectDir: string, watchedPath: string): boolean { const relativePath = path.relative(projectDir, watchedPath); if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { return false; } - const parts = relativePath.split(path.sep).filter(Boolean); + const parts = relativePath.split(/[/\\]+/).filter(Boolean); return parts[0] === BOARD_TASK_CHANGES_DIRNAME; } @@ -399,9 +396,7 @@ export class TeamLogSourceTracker { try { const taskId = decodeURIComponent(encodedTaskId); - return taskId.trim().length > 0 - ? { kind: 'task-id', taskId } - : { kind: 'invalid' }; + return taskId.trim().length > 0 ? { kind: 'task-id', taskId } : { kind: 'invalid' }; } catch { return { kind: 'invalid' }; } diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index a778d74f..e7a59938 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -40,6 +40,30 @@ export function encodePath(absolutePath: string): string { return encoded.startsWith('-') ? encoded : `-${encoded}`; } +function isWindowsAbsolutePathLike(name: string): boolean { + const slashPath = name.replace(/\\/g, '/'); + return /^[a-zA-Z]:\//.test(slashPath) || slashPath.startsWith('//'); +} + +function normalizeWindowsPathForStorageKey(name: string): string { + if (!isWindowsAbsolutePathLike(name)) { + return name; + } + return name.replace(/\\/g, '/').toLowerCase(); +} + +/** + * Matches the orchestrator's cross-platform storage key codec. + * It lowercases Windows absolute paths, normalizes separators, and replaces + * every non-ASCII-alphanumeric character with a dash. + */ +export function encodePathPortable(absolutePath: string): string { + if (!absolutePath) { + return ''; + } + return normalizeWindowsPathForStorageKey(absolutePath).replace(/[^a-zA-Z0-9]/g, '-'); +} + /** * Decodes a project directory name to its original path. * Note: This is a best-effort decode. Paths with dashes cannot be decoded accurately. @@ -133,7 +157,7 @@ export function isValidEncodedPath(encodedName: string): boolean { // Support legacy Windows format: "C--Users-name-project" // (no leading dash, drive separator encoded as "--"). - if (/^[a-zA-Z]--[a-zA-Z0-9_.\s-]+$/.test(encodedName)) { + if (/^[a-zA-Z]--[^\x00-\x1f/\\:*?"<>|]+$/u.test(encodedName)) { return true; } @@ -142,11 +166,10 @@ export function isValidEncodedPath(encodedName: string): boolean { return false; } - // Allow only expected encoded characters: - // - alphanumeric, underscores, dots, spaces, dashes - // - optional ":" for Windows drive notation (e.g., -C:-Users-name-project) - const validPattern = /^-[a-zA-Z0-9_.\s:-]+$/; - if (!validPattern.test(encodedName)) { + // Encoded path is a single directory name. It may contain Unicode project + // names, but must not contain separators, control chars, or Windows-invalid chars. + // A single drive colon is allowed only in the old "-C:-Users-name" form. + if (/[\x00-\x1f/\\*?"<>|]/u.test(encodedName)) { return false; } @@ -202,6 +225,35 @@ export function extractBaseDir(projectId: string): string { return projectId; } +function addUniqueCandidate(candidates: string[], candidate: string): void { + if (candidate && !candidates.includes(candidate)) { + candidates.push(candidate); + } +} + +/** + * Returns possible ~/.claude/projects directory names for a project id. + * The first candidate is always the id's own base dir. Additional entries cover + * the orchestrator's portable codec, which lowercases Windows paths and folds + * underscores/non-ASCII characters to dashes. + */ +export function getProjectDirNameCandidates(projectId: string): string[] { + const baseDir = extractBaseDir(projectId); + const candidates: string[] = []; + addUniqueCandidate(candidates, baseDir); + + const decoded = decodePath(baseDir); + addUniqueCandidate(candidates, encodePath(decoded)); + addUniqueCandidate(candidates, encodePathPortable(decoded)); + + if (path.isAbsolute(projectId)) { + addUniqueCandidate(candidates, encodePath(projectId)); + addUniqueCandidate(candidates, encodePathPortable(projectId)); + } + + return candidates; +} + // ============================================================================= // Session ID Extraction // ============================================================================= diff --git a/src/main/utils/pathValidation.ts b/src/main/utils/pathValidation.ts index 63e50334..9e58601d 100644 --- a/src/main/utils/pathValidation.ts +++ b/src/main/utils/pathValidation.ts @@ -70,9 +70,11 @@ function normalizeForCompare(input: string, isWindows: boolean): string { } export function isPathWithinRoot(targetPath: string, rootPath: string): boolean { - const target = path.resolve(targetPath); - const root = path.resolve(rootPath); - return target === root || target.startsWith(root + path.sep); + const isWindows = process.platform === 'win32'; + const target = normalizeForCompare(targetPath, isWindows); + const root = normalizeForCompare(rootPath, isWindows); + const relative = path.relative(root, target); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); } function resolveRealPathIfExists(inputPath: string): string | null { @@ -317,6 +319,47 @@ const MAX_FILENAME_LENGTH = 255; /** Characters forbidden in file/directory names. */ // eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- Intentional: validating filenames against control characters const INVALID_FILENAME_CHARS = /[\x00-\x1f/\\:*?"<>|]/; +const WINDOWS_RESERVED_BASENAMES = new Set([ + 'con', + 'prn', + 'aux', + 'nul', + 'com1', + 'com2', + 'com3', + 'com4', + 'com5', + 'com6', + 'com7', + 'com8', + 'com9', + 'lpt1', + 'lpt2', + 'lpt3', + 'lpt4', + 'lpt5', + 'lpt6', + 'lpt7', + 'lpt8', + 'lpt9', +]); + +export function isWindowsReservedFileName(name: string): boolean { + if (typeof name !== 'string') { + return false; + } + + const normalized = name + .trim() + .replace(/[. ]+$/g, '') + .toLowerCase(); + if (!normalized) { + return false; + } + + const stem = normalized.split('.')[0] ?? normalized; + return WINDOWS_RESERVED_BASENAMES.has(stem); +} /** * Validates a file or directory name for creation. @@ -344,6 +387,14 @@ export function validateFileName(name: string): PathValidationResult { return { valid: false, error: 'Name contains invalid characters' }; } + if (/[. ]$/.test(name)) { + return { valid: false, error: 'Name cannot end with a space or period' }; + } + + if (isWindowsReservedFileName(trimmed)) { + return { valid: false, error: 'Name is reserved on Windows' }; + } + return { valid: true }; } diff --git a/test/main/ipc/guards.test.ts b/test/main/ipc/guards.test.ts index ff0cc1d9..e04264af 100644 --- a/test/main/ipc/guards.test.ts +++ b/test/main/ipc/guards.test.ts @@ -75,4 +75,11 @@ describe('ipc guards', () => { expect(validateMemberName('alice bob').valid).toBe(false); expect(validateFromField('../../etc').valid).toBe(false); }); + + it('rejects Windows reserved device names for filesystem-backed fields', () => { + expect(validateTeamName('con').valid).toBe(false); + expect(validateTaskId('NUL').valid).toBe(false); + expect(validateMemberName('com1').valid).toBe(false); + expect(validateMemberName('lpt9.txt').valid).toBe(false); + }); }); diff --git a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts index 5a851846..62c216c1 100644 --- a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts +++ b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ProjectScanner } from '../../../../src/main/services/discovery/ProjectScanner'; import { subprojectRegistry } from '../../../../src/main/services/discovery/SubprojectRegistry'; +import { encodePathPortable } from '../../../../src/main/utils/pathDecoder'; function createSessionLine(opts: { cwd?: string; type?: string }): string { return JSON.stringify({ @@ -102,4 +103,42 @@ describe('ProjectScanner cwd split logic', () => { expect(proj.id).toContain('::'); } }); + + it('finds sessions stored with the orchestrator Windows project codec', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-')); + tempDirs.push(projectsDir); + + const projectPath = 'C:\\Users\\User\\PROJECT_IT\\сlaude_team'; + const uiEncodedName = 'C--Users-User-PROJECT_IT-сlaude_team'; + const orchestratorEncodedName = encodePathPortable(projectPath); + const projectDir = path.join(projectsDir, orchestratorEncodedName); + fs.mkdirSync(projectDir); + + const sessionPath = path.join(projectDir, 'session-orchestrator.jsonl'); + fs.writeFileSync(sessionPath, createSessionLine({ cwd: projectPath }) + '\n'); + + const scanner = new ProjectScanner(projectsDir); + await expect(scanner.listSessionFiles(uiEncodedName)).resolves.toEqual([sessionPath]); + }); + + it('detects Windows forward-slash worktree paths', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-')); + tempDirs.push(projectsDir); + + const encodedName = 'c--users-test--claude-worktrees-myrepo-feature'; + const projectDir = path.join(projectsDir, encodedName); + fs.mkdirSync(projectDir); + + fs.writeFileSync( + path.join(projectDir, 'session-worktree.jsonl'), + createSessionLine({ cwd: 'C:/Users/test/.claude-worktrees/myrepo/feature' }) + '\n' + ); + + const scanner = new ProjectScanner(projectsDir); + const groups = await scanner.scanWithWorktreeGrouping(); + const worktree = groups.find((group) => group.id === encodedName)?.worktrees[0]; + + expect(worktree?.isMainWorktree).toBe(false); + expect(worktree?.source).toBe('claude-desktop'); + }); }); diff --git a/test/main/utils/pathDecoder.test.ts b/test/main/utils/pathDecoder.test.ts index 5805be88..5183095e 100644 --- a/test/main/utils/pathDecoder.test.ts +++ b/test/main/utils/pathDecoder.test.ts @@ -10,8 +10,10 @@ import { buildTodoPath, decodePath, encodePath, + encodePathPortable, extractProjectName, extractSessionId, + getProjectDirNameCandidates, getAppDataPath, getProjectsBasePath, getSchedulesBasePath, @@ -72,6 +74,12 @@ describe('pathDecoder', () => { it('should encode a Linux-style path', () => { expect(encodePath('/home/user/projects/myapp')).toBe('-home-user-projects-myapp'); }); + + it('should produce orchestrator-compatible Windows storage keys', () => { + expect(encodePathPortable('C:\\Users\\User\\PROJECT_IT\\сlaude_team')).toBe( + 'c--users-user-project-it--laude-team' + ); + }); }); describe('decodePath', () => { @@ -188,12 +196,27 @@ describe('pathDecoder', () => { expect(isValidEncodedPath('C--Users-username-projectname')).toBe(true); }); + it('should return true for Windows encoded paths with underscores and Unicode', () => { + expect(isValidEncodedPath('C--Users-User-PROJECT_IT-сlaude_team')).toBe(true); + }); + it('should return false for misplaced colons', () => { expect(isValidEncodedPath('-Users-username:project')).toBe(false); expect(isValidEncodedPath('-C:-Users-name-project:extra')).toBe(false); }); }); + describe('getProjectDirNameCandidates', () => { + it('includes the orchestrator storage key for the current Windows project path shape', () => { + expect(getProjectDirNameCandidates('C--Users-User-PROJECT_IT-сlaude_team')).toEqual( + expect.arrayContaining([ + 'C--Users-User-PROJECT_IT-сlaude_team', + 'c--users-user-project-it--laude-team', + ]) + ); + }); + }); + describe('extractSessionId', () => { it('should extract session ID from JSONL filename', () => { expect(extractSessionId('abc123.jsonl')).toBe('abc123'); diff --git a/test/main/utils/pathValidation.test.ts b/test/main/utils/pathValidation.test.ts index dcaf8e47..9965f72f 100644 --- a/test/main/utils/pathValidation.test.ts +++ b/test/main/utils/pathValidation.test.ts @@ -10,7 +10,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { getHomeDir, setClaudeBasePathOverride } from '../../../src/main/utils/pathDecoder'; import { + isPathWithinRoot, isPathWithinAllowedDirectories, + isWindowsReservedFileName, + validateFileName, validateFilePath, validateOpenPath, validateOpenPathUserSelected, @@ -62,6 +65,41 @@ describe('pathValidation', () => { }); }); + describe('isPathWithinRoot', () => { + it('rejects sibling paths that only share the same prefix', () => { + const root = path.join(os.tmpdir(), 'repo'); + const sibling = path.join(os.tmpdir(), 'repo2', 'file.ts'); + expect(isPathWithinRoot(sibling, root)).toBe(false); + }); + + it('handles Windows drive casing and traversal consistently', () => { + if (process.platform !== 'win32') { + return; + } + + expect(isPathWithinRoot('C:\\Repo\\File.ts', 'c:\\repo')).toBe(true); + expect(isPathWithinRoot('c:\\repo\\file.ts', 'C:\\Repo')).toBe(true); + expect(isPathWithinRoot('C:\\Repo2\\file.ts', 'C:\\Repo')).toBe(false); + expect(isPathWithinRoot('C:\\Repo\\..\\escape\\file.ts', 'C:\\Repo')).toBe(false); + }); + }); + + describe('validateFileName', () => { + it('rejects Windows reserved basenames before file creation', () => { + expect(isWindowsReservedFileName('con')).toBe(true); + expect(isWindowsReservedFileName('NUL.txt')).toBe(true); + expect(isWindowsReservedFileName('com1.json')).toBe(true); + expect(validateFileName('con').valid).toBe(false); + expect(validateFileName('NUL.txt').valid).toBe(false); + expect(validateFileName('com1.json').valid).toBe(false); + }); + + it('rejects trailing spaces and periods for Windows-safe names', () => { + expect(validateFileName('report.').valid).toBe(false); + expect(validateFileName('report ').valid).toBe(false); + }); + }); + describe('validateFilePath', () => { describe('basic validation', () => { it('should reject empty path', () => { From 3f1c1acb4f3f76b9fc7cc25772c5d8cd6224d697 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 26 Apr 2026 11:44:35 +0300 Subject: [PATCH 2/3] fix(windows): harden runtime project compatibility --- .../renderer/hooks/useOpenRecentProject.ts | 3 +- .../renderer/utils/navigation.ts | 16 ++- .../buildTmuxEffectiveAvailability.test.ts | 4 +- .../buildTmuxEffectiveAvailability.ts | 7 +- .../main/infrastructure/wsl/TmuxWslService.ts | 6 + .../wsl/__tests__/TmuxWslService.test.ts | 16 +++ .../analysis/SubagentDetailBuilder.ts | 18 +-- src/main/services/discovery/ProjectScanner.ts | 36 +++--- .../services/discovery/SessionSearcher.ts | 16 +-- .../services/discovery/SubagentLocator.ts | 28 ++++- .../services/discovery/projectStorageDir.ts | 32 ++++++ .../services/editor/ProjectFileService.ts | 2 +- .../extensions/skills/SkillsCatalogService.ts | 13 ++- src/main/services/parsing/SessionParser.ts | 4 +- .../services/team/ClaudeBinaryResolver.ts | 24 ++-- .../services/team/TeamProvisioningService.ts | 103 ++++++++++++----- src/main/utils/windowsProcessTable.ts | 105 ++++++++++++++++++ .../renderer/utils/navigation.test.ts | 11 +- .../discovery/ProjectScanner.cwdSplit.test.ts | 20 +++- .../team/ClaudeBinaryResolver.test.ts | 31 ++++++ test/main/utils/windowsProcessTable.test.ts | 42 +++++++ 21 files changed, 438 insertions(+), 99 deletions(-) create mode 100644 src/main/services/discovery/projectStorageDir.ts create mode 100644 src/main/utils/windowsProcessTable.ts create mode 100644 test/main/utils/windowsProcessTable.test.ts diff --git a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts index a4eb21d7..fb7fd848 100644 --- a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts +++ b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts @@ -13,6 +13,7 @@ import { useShallow } from 'zustand/react/shallow'; import { buildSyntheticRepositoryGroup, + encodeProjectPathForNavigation, findMatchingWorktree, type WorktreeMatch, } from '../utils/navigation'; @@ -79,7 +80,7 @@ export function useOpenRecentProject(): { repositoryGroups: [buildSyntheticRepositoryGroup(path), ...state.repositoryGroups], })); - const encodedId = path.replace(/[/\\]/g, '-'); + const encodedId = encodeProjectPathForNavigation(path); navigateToMatch({ repoId: encodedId, worktreeId: encodedId }); }, [fetchRepositoryGroups, navigateToMatch, repositoryGroups] diff --git a/src/features/recent-projects/renderer/utils/navigation.ts b/src/features/recent-projects/renderer/utils/navigation.ts index 3dff676a..747eea58 100644 --- a/src/features/recent-projects/renderer/utils/navigation.ts +++ b/src/features/recent-projects/renderer/utils/navigation.ts @@ -24,8 +24,22 @@ export function findMatchingWorktree( return null; } +export function encodeProjectPathForNavigation(projectPath: string): string { + if (!projectPath) { + return ''; + } + + const encoded = projectPath.replace(/[/\\]/g, '-'); + const windowsDriveMatch = /^([a-zA-Z]):-(.*)$/.exec(encoded); + if (windowsDriveMatch) { + return `${windowsDriveMatch[1].toUpperCase()}--${windowsDriveMatch[2]}`; + } + + return encoded.startsWith('-') ? encoded : `-${encoded}`; +} + export function buildSyntheticRepositoryGroup(selectedPath: string): RepositoryGroup { - const encodedId = selectedPath.replace(/[/\\]/g, '-'); + const encodedId = encodeProjectPathForNavigation(selectedPath); const folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath; const now = Date.now(); diff --git a/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxEffectiveAvailability.test.ts b/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxEffectiveAvailability.test.ts index 2f9f3f23..737eaff4 100644 --- a/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxEffectiveAvailability.test.ts +++ b/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxEffectiveAvailability.test.ts @@ -21,7 +21,7 @@ describe('buildTmuxEffectiveAvailability', () => { expect(result.runtimeReady).toBe(true); }); - it('prefers WSL tmux on Windows when it is available', () => { + it('keeps WSL tmux visible but non-runtime-ready on Windows', () => { const result = buildTmuxEffectiveAvailability({ platform: 'win32', nativeSupported: false, @@ -47,7 +47,7 @@ describe('buildTmuxEffectiveAvailability', () => { expect(result.available).toBe(true); expect(result.location).toBe('wsl'); - expect(result.runtimeReady).toBe(true); + expect(result.runtimeReady).toBe(false); expect(result.version).toBe('tmux 3.4'); }); diff --git a/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts b/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts index cb4a0bf0..b6fe3d0c 100644 --- a/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts +++ b/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts @@ -22,10 +22,9 @@ export function buildTmuxEffectiveAvailability( location: 'wsl', version: input.wsl.tmuxVersion, binaryPath: input.wsl.tmuxBinaryPath, - runtimeReady: input.wsl.distroBootstrapped, - detail: input.wsl.distroBootstrapped - ? 'tmux is available inside WSL for the persistent teammate runtime.' - : 'tmux is installed inside WSL, but the Linux distro still needs first-launch setup.', + runtimeReady: false, + detail: + 'tmux is available inside WSL, but the persistent teammate runtime still needs native Windows pane support.', }; } diff --git a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts index 84cc73a1..b6ccfcda 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts @@ -420,9 +420,15 @@ export class TmuxWslService { .split(/\r?\n/) .map((line) => line.replace(/\0/g, '').trim()) .map((line) => line.replace(/^\*\s*/, '').trim()) + .filter((line) => !this.#isInternalWslDistro(line)) .filter(Boolean); } + #isInternalWslDistro(name: string): boolean { + const normalized = name.trim().toLowerCase(); + return normalized === 'docker-desktop' || normalized === 'docker-desktop-data'; + } + #parseVerboseDistroEntries(stdout: string, distros: string[]): WslVerboseDistroEntry[] { const sortedDistros = [...distros].sort((left, right) => right.length - left.length); const entries: WslVerboseDistroEntry[] = []; diff --git a/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts index e1ed78f5..c65aa4a7 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts @@ -149,6 +149,22 @@ describe('TmuxWslService', () => { expect(preferenceStore.getPreferredDistroSync()).toBeNull(); }); + it('ignores Docker internal WSL distros when choosing a teammate runtime distro', async () => { + const service = new TmuxWslService( + createExecFileMock({ + '--status': { stdout: 'Default Distribution: docker-desktop\nDefault Version: 2\n' }, + '--list --quiet': { stdout: 'docker-desktop\ndocker-desktop-data\n' }, + }), + createPreferenceStore() as never + ); + + const result = await service.probe(); + + expect(result.status.wslInstalled).toBe(true); + expect(result.status.distroName).toBeNull(); + expect(result.status.statusDetail).toContain('no Linux distribution'); + }); + it('switches preference source away from persisted after clearing a stale distro', async () => { const preferenceStore = createPreferenceStore('Ubuntu'); const service = new TmuxWslService( diff --git a/src/main/services/analysis/SubagentDetailBuilder.ts b/src/main/services/analysis/SubagentDetailBuilder.ts index 2b7f7480..93b6c192 100644 --- a/src/main/services/analysis/SubagentDetailBuilder.ts +++ b/src/main/services/analysis/SubagentDetailBuilder.ts @@ -14,19 +14,19 @@ import { type SemanticStepGroup, type SubagentDetail, } from '@main/types'; -import { extractBaseDir } from '@main/utils/pathDecoder'; import { countTokens } from '@main/utils/tokenizer'; import { createLogger } from '@shared/utils/logger'; import * as path from 'path'; -const logger = createLogger('Service:SubagentDetailBuilder'); - import { buildSemanticStepGroups } from './SemanticStepGrouper'; +import { resolveProjectStorageDir } from '../discovery/projectStorageDir'; import type { SubagentResolver } from '../discovery/SubagentResolver'; import type { FileSystemProvider } from '../infrastructure/FileSystemProvider'; import type { SessionParser } from '../parsing/SessionParser'; +const logger = createLogger('Service:SubagentDetailBuilder'); + /** * Build detailed information for a specific subagent. * Used for drill-down modal to show subagent's internal execution. @@ -52,12 +52,14 @@ export async function buildSubagentDetail( projectsDir: string ): Promise { try { - // Construct path to subagent JSONL file - // projectId may be composite (e.g. "baseDir::suffix"), extract base dir - const baseDir = extractBaseDir(projectId); + const projectPath = await resolveProjectStorageDir(projectsDir, projectId, fsProvider); + if (!projectPath) { + logger.warn(`Project storage directory not found for subagent detail: ${projectId}`); + return null; + } + const subagentPath = path.join( - projectsDir, - baseDir, + projectPath, sessionId, 'subagents', `agent-${subagentId}.jsonl` diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 38337f23..e95fb9e1 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -47,7 +47,6 @@ import { extractBaseDir, extractProjectName, extractSessionId, - getProjectDirNameCandidates, getProjectsBasePath, getTodosBasePath, isValidEncodedPath, @@ -59,6 +58,7 @@ import { configManager } from '../infrastructure/ConfigManager'; import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider'; import { ProjectPathResolver } from './ProjectPathResolver'; +import { resolveProjectStorageDir as resolveProjectStorageDirFromCandidates } from './projectStorageDir'; import { SessionContentFilter } from './SessionContentFilter'; import { SessionSearcher } from './SessionSearcher'; import { SubagentLocator } from './SubagentLocator'; @@ -633,13 +633,12 @@ export class ProjectScanner { */ async listSessions(projectId: string): Promise { try { - const baseDir = extractBaseDir(projectId); - const projectPath = path.join(this.projectsDir, baseDir); + const projectPath = await this.resolveProjectStorageDir(projectId); const sessionFilter = await this.getSessionFilterForProject(projectId); const shouldFilterNoise = this.fsProvider.type !== 'ssh'; const metadataLevel: SessionMetadataLevel = this.fsProvider.type === 'ssh' ? 'light' : 'deep'; - if (!(await this.fsProvider.exists(projectPath))) { + if (!projectPath) { return []; } @@ -722,14 +721,13 @@ export class ProjectScanner { try { const includeTotalCount = options?.includeTotalCount ?? false; const prefilterAll = options?.prefilterAll ?? false; - const baseDir = extractBaseDir(projectId); - const projectPath = path.join(this.projectsDir, baseDir); + const projectPath = await this.resolveProjectStorageDir(projectId); const sessionFilter = await this.getSessionFilterForProject(projectId); const shouldFilterNoise = this.fsProvider.type !== 'ssh'; const metadataLevel: SessionMetadataLevel = options?.metadataLevel ?? (this.fsProvider.type === 'ssh' ? 'light' : 'deep'); - if (!(await this.fsProvider.exists(projectPath))) { + if (!projectPath) { return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 }; } @@ -1140,9 +1138,9 @@ export class ProjectScanner { * Gets a single session's metadata. */ async getSession(projectId: string, sessionId: string): Promise { - const filePath = this.getSessionPath(projectId, sessionId); + const filePath = await this.resolveSessionPath(projectId, sessionId); - if (!(await this.fsProvider.exists(filePath))) { + if (!filePath || !(await this.fsProvider.exists(filePath))) { return null; } @@ -1159,9 +1157,9 @@ export class ProjectScanner { sessionId: string, options?: SessionsByIdsOptions ): Promise { - const filePath = this.getSessionPath(projectId, sessionId); + const filePath = await this.resolveSessionPath(projectId, sessionId); - if (!(await this.fsProvider.exists(filePath))) { + if (!filePath || !(await this.fsProvider.exists(filePath))) { return null; } @@ -1207,6 +1205,14 @@ export class ProjectScanner { return buildSessionPath(this.projectsDir, projectId, sessionId); } + /** + * Resolves a session path using all known project storage directory codecs. + */ + async resolveSessionPath(projectId: string, sessionId: string): Promise { + const projectPath = await this.resolveProjectStorageDir(projectId); + return projectPath ? path.join(projectPath, `${sessionId}.jsonl`) : null; + } + /** * Gets the path to the subagents directory. */ @@ -1242,13 +1248,7 @@ export class ProjectScanner { } private async resolveProjectStorageDir(projectId: string): Promise { - for (const dirName of getProjectDirNameCandidates(projectId)) { - const projectPath = path.join(this.projectsDir, dirName); - if (await this.fsProvider.exists(projectPath)) { - return projectPath; - } - } - return null; + return resolveProjectStorageDirFromCandidates(this.projectsDir, projectId, this.fsProvider); } /** diff --git a/src/main/services/discovery/SessionSearcher.ts b/src/main/services/discovery/SessionSearcher.ts index 0d1b1857..4bb72868 100644 --- a/src/main/services/discovery/SessionSearcher.ts +++ b/src/main/services/discovery/SessionSearcher.ts @@ -13,7 +13,7 @@ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; import { parseJsonlFile } from '@main/utils/jsonl'; -import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder'; +import { extractSessionId } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import * as path from 'path'; @@ -21,6 +21,7 @@ import { startMainSpan } from '../../sentry'; import { SearchTextCache } from './SearchTextCache'; import { extractSearchableEntries } from './SearchTextExtractor'; +import { resolveProjectStorageDir } from './projectStorageDir'; import { subprojectRegistry } from './SubprojectRegistry'; import type { SearchableEntry } from './SearchTextExtractor'; @@ -74,11 +75,14 @@ export class SessionSearcher { const normalizedQuery = query.toLowerCase().trim(); try { - const baseDir = extractBaseDir(projectId); - const projectPath = path.join(this.projectsDir, baseDir); + const projectPath = await resolveProjectStorageDir( + this.projectsDir, + projectId, + this.fsProvider + ); const sessionFilter = subprojectRegistry.getSessionFilter(projectId); - if (!(await this.fsProvider.exists(projectPath))) { + if (!projectPath) { return { results: [], totalMatches: 0, sessionsSearched: 0, query }; } @@ -283,9 +287,7 @@ export class SessionSearcher { sessionTitle: sessionTitle ?? 'Untitled Session', matchedText, context: - (contextStart > 0 ? '...' : '') + - context + - (contextEnd < entry.text.length ? '...' : ''), + (contextStart > 0 ? '...' : '') + context + (contextEnd < entry.text.length ? '...' : ''), messageType: entry.messageType, timestamp: entry.timestamp, groupId: entry.groupId, diff --git a/src/main/services/discovery/SubagentLocator.ts b/src/main/services/discovery/SubagentLocator.ts index 961d2620..fdb32c00 100644 --- a/src/main/services/discovery/SubagentLocator.ts +++ b/src/main/services/discovery/SubagentLocator.ts @@ -9,11 +9,13 @@ */ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; -import { buildSubagentsPath, extractBaseDir } from '@main/utils/pathDecoder'; +import { buildSubagentsPath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; +import { resolveProjectStorageDir, resolveProjectStorageDirSync } from './projectStorageDir'; + import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider'; const logger = createLogger('Discovery:SubagentLocator'); @@ -40,7 +42,10 @@ export class SubagentLocator { */ async hasSubagents(projectId: string, sessionId: string): Promise { // Check NEW structure: {projectId}/{sessionId}/subagents/ - const newSubagentsPath = this.getSubagentsPath(projectId, sessionId); + const newSubagentsPath = await this.resolveSubagentsPath(projectId, sessionId); + if (!newSubagentsPath) { + return false; + } try { const entries = await this.fsProvider.readdir(newSubagentsPath); // A non-empty agent-*.jsonl file is sufficient proof of subagents. @@ -93,7 +98,10 @@ export class SubagentLocator { */ async listSubagentFiles(projectId: string, sessionId: string): Promise { try { - const newSubagentsPath = this.getSubagentsPath(projectId, sessionId); + const newSubagentsPath = await this.resolveSubagentsPath(projectId, sessionId); + if (!newSubagentsPath) { + return []; + } if (await this.fsProvider.exists(newSubagentsPath)) { const entries = await this.fsProvider.readdir(newSubagentsPath); return entries @@ -118,6 +126,18 @@ export class SubagentLocator { * @returns Path to the subagents directory */ getSubagentsPath(projectId: string, sessionId: string): string { - return buildSubagentsPath(this.projectsDir, projectId, sessionId); + const projectPath = resolveProjectStorageDirSync(this.projectsDir, projectId); + return projectPath + ? path.join(projectPath, sessionId, 'subagents') + : buildSubagentsPath(this.projectsDir, projectId, sessionId); + } + + private async resolveSubagentsPath(projectId: string, sessionId: string): Promise { + const projectPath = await resolveProjectStorageDir( + this.projectsDir, + projectId, + this.fsProvider + ); + return projectPath ? path.join(projectPath, sessionId, 'subagents') : null; } } diff --git a/src/main/services/discovery/projectStorageDir.ts b/src/main/services/discovery/projectStorageDir.ts new file mode 100644 index 00000000..21faf21f --- /dev/null +++ b/src/main/services/discovery/projectStorageDir.ts @@ -0,0 +1,32 @@ +import { getProjectDirNameCandidates } from '@main/utils/pathDecoder'; +import * as fs from 'fs'; +import * as path from 'path'; + +import type { FileSystemProvider } from '../infrastructure/FileSystemProvider'; + +export async function resolveProjectStorageDir( + projectsDir: string, + projectId: string, + fsProvider: FileSystemProvider +): Promise { + for (const dirName of getProjectDirNameCandidates(projectId)) { + const projectPath = path.join(projectsDir, dirName); + if (await fsProvider.exists(projectPath)) { + return projectPath; + } + } + return null; +} + +export function resolveProjectStorageDirSync( + projectsDir: string, + projectId: string +): string | null { + for (const dirName of getProjectDirNameCandidates(projectId)) { + const projectPath = path.join(projectsDir, dirName); + if (fs.existsSync(projectPath)) { + return projectPath; + } + } + return null; +} diff --git a/src/main/services/editor/ProjectFileService.ts b/src/main/services/editor/ProjectFileService.ts index e203770c..e846bfb0 100644 --- a/src/main/services/editor/ProjectFileService.ts +++ b/src/main/services/editor/ProjectFileService.ts @@ -547,7 +547,7 @@ export class ProjectFileService { const newPath = path.join(normalizedDest, path.basename(normalizedSrc)); // 8. Prevent parent → child move (moving dir into itself) - if (normalizedDest.startsWith(normalizedSrc + path.sep) || normalizedDest === normalizedSrc) { + if (isPathWithinRoot(normalizedDest, normalizedSrc)) { throw new Error('Cannot move a directory into itself'); } diff --git a/src/main/services/extensions/skills/SkillsCatalogService.ts b/src/main/services/extensions/skills/SkillsCatalogService.ts index f207ffd8..be82d7ca 100644 --- a/src/main/services/extensions/skills/SkillsCatalogService.ts +++ b/src/main/services/extensions/skills/SkillsCatalogService.ts @@ -77,11 +77,16 @@ export class SkillsCatalogService { } private isWithinRoot(targetPath: string, rootPath: string): boolean { - const normalizedTarget = path.resolve(targetPath); - const normalizedRoot = path.resolve(rootPath); + const normalizedTarget = this.normalizeForContainment(targetPath); + const normalizedRoot = this.normalizeForContainment(rootPath); + const relativePath = path.relative(normalizedRoot, normalizedTarget); return ( - normalizedTarget === normalizedRoot || - normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`) + relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) ); } + + private normalizeForContainment(value: string): string { + const resolved = path.resolve(path.normalize(value)); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; + } } diff --git a/src/main/services/parsing/SessionParser.ts b/src/main/services/parsing/SessionParser.ts index fc5c85bd..3219f4b6 100644 --- a/src/main/services/parsing/SessionParser.ts +++ b/src/main/services/parsing/SessionParser.ts @@ -67,7 +67,9 @@ export class SessionParser { * Parse a session JSONL file and return structured data. */ async parseSession(projectId: string, sessionId: string): Promise { - const sessionPath = this.projectScanner.getSessionPath(projectId, sessionId); + const sessionPath = + (await this.projectScanner.resolveSessionPath(projectId, sessionId)) ?? + this.projectScanner.getSessionPath(projectId, sessionId); return this.parseSessionFile(sessionPath); } diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index 43e9b006..9827ff3b 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -147,25 +147,19 @@ async function resolveFromExplicitPath(inputPath: string): Promise => { + if (windowsHostProcessRows) { + return windowsHostProcessRows; + } + try { + windowsHostProcessRows = await listWindowsProcessTable(); + windowsHostProcessTableAvailable = true; + } catch (error) { + windowsHostProcessRows = []; + logger.debug( + `[${teamName}] Failed to read Windows host process table for runtime snapshot: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + return windowsHostProcessRows; + }; for (const [memberName, metadata] of metadataByMember.entries()) { const paneId = metadata.tmuxPaneId?.trim() ?? ''; @@ -15106,6 +15130,20 @@ export class TeamProvisioningService { } : undefined; const status = this.findTrackedMemberSpawnStatus(run, memberName) ?? adapterStatus; + const shouldUseWindowsHostRows = + process.platform === 'win32' && + (metadata.providerId === 'opencode' || + launchMember?.providerId === 'opencode' || + metadata.backendType !== 'tmux') && + currentRuntimeAdapterRun?.members?.[memberName]?.runtimeAlive !== true && + currentRuntimeAdapterRun?.members?.[memberName]?.bootstrapConfirmed !== true; + const hostProcessRows = shouldUseWindowsHostRows ? await getWindowsHostProcessRows() : []; + const memberProcessRows = shouldUseWindowsHostRows + ? [...hostProcessRows, ...processRows] + : processRows; + const memberProcessTableAvailable = shouldUseWindowsHostRows + ? windowsHostProcessTableAvailable || processTableAvailable + : processTableAvailable; const resolved = resolveTeamMemberRuntimeLiveness({ teamName, memberName, @@ -15119,8 +15157,8 @@ export class TeamProvisioningService { runtimePid: metadata.metricsPid, runtimeSessionId: metadata.runtimeSessionId, pane: paneId ? paneInfoById.get(paneId) : undefined, - processRows, - processTableAvailable, + processRows: memberProcessRows, + processTableAvailable: memberProcessTableAvailable, nowIso: nowIso(), }); metadataByMember.set(memberName, { @@ -17638,37 +17676,50 @@ export class TeamProvisioningService { } private killOrphanedTeamAgentProcesses(teamName: string): void { - if (process.platform === 'win32') { - return; - } - - let output = ''; - try { - output = execFileSync('ps', ['-ax', '-o', 'pid=,command='], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }); - } catch { - return; - } - const currentRunPid = this.getTrackedRunId(teamName) ? this.runs.get(this.getTrackedRunId(teamName)!)?.child?.pid : undefined; - const marker = `--team-name ${teamName}`; const pids = new Set(); + const rows: Array<{ pid: number; command: string }> = []; - for (const line of output.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || !trimmed.includes(marker) || !trimmed.includes('--agent-id')) { + if (process.platform === 'win32') { + try { + rows.push( + ...listWindowsProcessTableSync().map((row) => ({ pid: row.pid, command: row.command })) + ); + } catch { + return; + } + } else { + let output = ''; + try { + output = execFileSync('ps', ['-ax', '-o', 'pid=,command='], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return; + } + + for (const line of output.split('\n')) { + const trimmed = line.trim(); + const match = /^(\d+)\s+(.*)$/.exec(trimmed); + if (!match) continue; + const pid = Number.parseInt(match[1], 10); + if (!Number.isFinite(pid) || pid <= 0) continue; + rows.push({ pid, command: match[2] ?? '' }); + } + } + + for (const row of rows) { + if ( + !commandArgEquals(row.command, '--team-name', teamName) || + !row.command.includes('--agent-id') + ) { continue; } - const match = /^(\d+)\s+(.*)$/.exec(trimmed); - if (!match) continue; - const pid = Number.parseInt(match[1], 10); - if (!Number.isFinite(pid) || pid <= 0) continue; - if (currentRunPid && pid === currentRunPid) continue; - pids.add(pid); + if (currentRunPid && row.pid === currentRunPid) continue; + pids.add(row.pid); } for (const pid of pids) { diff --git a/src/main/utils/windowsProcessTable.ts b/src/main/utils/windowsProcessTable.ts new file mode 100644 index 00000000..70e94e8f --- /dev/null +++ b/src/main/utils/windowsProcessTable.ts @@ -0,0 +1,105 @@ +import { execFile, execFileSync } from 'child_process'; + +export interface WindowsProcessTableRow { + pid: number; + ppid: number; + command: string; +} + +interface RawWindowsProcessRow { + ProcessId?: number | string | null; + ParentProcessId?: number | string | null; + CommandLine?: string | null; +} + +const PROCESS_TABLE_SCRIPT = [ + '$ErrorActionPreference = "Stop"', + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress', +].join('; '); + +const PROCESS_TABLE_ARGS = [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + PROCESS_TABLE_SCRIPT, +]; + +function parsePositiveInteger(value: unknown): number | null { + const parsed = + typeof value === 'number' + ? value + : typeof value === 'string' + ? Number.parseInt(value, 10) + : Number.NaN; + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null; +} + +export function parseWindowsProcessTableJson(stdout: string): WindowsProcessTableRow[] { + const trimmed = stdout.trim(); + if (!trimmed) { + return []; + } + + let parsed: RawWindowsProcessRow | RawWindowsProcessRow[]; + try { + parsed = JSON.parse(trimmed) as RawWindowsProcessRow | RawWindowsProcessRow[]; + } catch { + return []; + } + + const rows = Array.isArray(parsed) ? parsed : [parsed]; + const result: WindowsProcessTableRow[] = []; + + for (const row of rows) { + const pid = parsePositiveInteger(row?.ProcessId); + const ppid = parsePositiveInteger(row?.ParentProcessId) ?? 0; + const command = row?.CommandLine?.trim() ?? ''; + if (!pid || !command) { + continue; + } + result.push({ pid, ppid, command }); + } + + return result; +} + +export async function listWindowsProcessTable( + timeoutMs = 4_000 +): Promise { + return new Promise((resolve, reject) => { + execFile( + 'powershell.exe', + PROCESS_TABLE_ARGS, + { + encoding: 'utf8', + timeout: timeoutMs, + windowsHide: true, + maxBuffer: 8 * 1024 * 1024, + }, + (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + if (stderr?.trim()) { + reject(new Error(stderr.trim())); + return; + } + resolve(parseWindowsProcessTableJson(String(stdout))); + } + ); + }); +} + +export function listWindowsProcessTableSync(timeoutMs = 4_000): WindowsProcessTableRow[] { + const stdout = execFileSync('powershell.exe', PROCESS_TABLE_ARGS, { + encoding: 'utf8', + timeout: timeoutMs, + windowsHide: true, + maxBuffer: 8 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return parseWindowsProcessTableJson(String(stdout)); +} diff --git a/test/features/recent-projects/renderer/utils/navigation.test.ts b/test/features/recent-projects/renderer/utils/navigation.test.ts index 61da4f9b..3ed056b6 100644 --- a/test/features/recent-projects/renderer/utils/navigation.test.ts +++ b/test/features/recent-projects/renderer/utils/navigation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { buildSyntheticRepositoryGroup, + encodeProjectPathForNavigation, findMatchingWorktree, } from '@features/recent-projects/renderer/utils/navigation'; @@ -31,9 +32,7 @@ describe('recent-projects navigation utils', () => { }, ]; - expect( - findMatchingWorktree(groups, ['/users/test/alpha/', '/users/test/other']) - ).toEqual({ + expect(findMatchingWorktree(groups, ['/users/test/alpha/', '/users/test/other'])).toEqual({ repoId: 'repo-alpha', worktreeId: 'wt-alpha', }); @@ -59,4 +58,10 @@ describe('recent-projects navigation utils', () => { vi.useRealTimers(); }); + + it('encodes Windows custom project paths with the same drive format as session ids', () => { + expect(encodeProjectPathForNavigation('C:\\Users\\User\\PROJECT_IT\\сlaude_team')).toBe( + 'C--Users-User-PROJECT_IT-сlaude_team' + ); + }); }); diff --git a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts index 62c216c1..a26ddc14 100644 --- a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts +++ b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts @@ -4,7 +4,9 @@ import * as path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; import { ProjectScanner } from '../../../../src/main/services/discovery/ProjectScanner'; +import { SessionSearcher } from '../../../../src/main/services/discovery/SessionSearcher'; import { subprojectRegistry } from '../../../../src/main/services/discovery/SubprojectRegistry'; +import { SessionParser } from '../../../../src/main/services/parsing/SessionParser'; import { encodePathPortable } from '../../../../src/main/utils/pathDecoder'; function createSessionLine(opts: { cwd?: string; type?: string }): string { @@ -51,10 +53,7 @@ describe('ProjectScanner cwd split logic', () => { // Session WITHOUT cwd (older format) fs.writeFileSync( path.join(projectDir, 'session-no-cwd.jsonl'), - createSessionLine({ type: 'system' }) + - '\n' + - createSessionLine({ type: 'user' }) + - '\n' + createSessionLine({ type: 'system' }) + '\n' + createSessionLine({ type: 'user' }) + '\n' ); const scanner = new ProjectScanner(projectsDir); @@ -119,6 +118,19 @@ describe('ProjectScanner cwd split logic', () => { const scanner = new ProjectScanner(projectsDir); await expect(scanner.listSessionFiles(uiEncodedName)).resolves.toEqual([sessionPath]); + await expect(scanner.listSessions(uiEncodedName)).resolves.toHaveLength(1); + await expect(scanner.getSession(uiEncodedName, 'session-orchestrator')).resolves.toMatchObject({ + id: 'session-orchestrator', + projectId: uiEncodedName, + }); + + const parser = new SessionParser(scanner); + const parsed = await parser.parseSession(uiEncodedName, 'session-orchestrator'); + expect(parsed.messages).toHaveLength(1); + + const searcher = new SessionSearcher(projectsDir); + const searchResult = await searcher.searchSessions(uiEncodedName, 'hello', 10); + expect(searchResult.totalMatches).toBe(1); }); it('detects Windows forward-slash worktree paths', async () => { diff --git a/test/main/services/team/ClaudeBinaryResolver.test.ts b/test/main/services/team/ClaudeBinaryResolver.test.ts index 33677735..2378db8e 100644 --- a/test/main/services/team/ClaudeBinaryResolver.test.ts +++ b/test/main/services/team/ClaudeBinaryResolver.test.ts @@ -53,6 +53,7 @@ describe('ClaudeBinaryResolver', () => { const originalPlatform = process.platform; const originalCwd = process.cwd; const originalResourcesPath = process.resourcesPath; + const originalPathext = process.env.PATHEXT; const workspaceRoot = '/Users/belief/dev/projects/claude/claude_team_runtime'; beforeEach(() => { @@ -91,6 +92,11 @@ describe('ClaudeBinaryResolver', () => { configurable: true, writable: true, }); + if (originalPathext === undefined) { + delete process.env.PATHEXT; + } else { + process.env.PATHEXT = originalPathext; + } vi.unstubAllEnvs(); }); @@ -130,6 +136,31 @@ describe('ClaudeBinaryResolver', () => { expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1); }); + it('resolves extensionless Windows explicit overrides to a real executable file first', async () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + writable: true, + }); + mockGetConfiguredCliFlavor.mockReturnValue('claude'); + process.env.PATHEXT = '.EXE;.CMD'; + process.env.CLAUDE_CLI_PATH = 'C:\\Tools\\claude'; + const expectedBinary = 'C:\\Tools\\claude.exe'; + + statMock.mockImplementation((filePath) => { + if (filePath === expectedBinary) { + return Promise.resolve({ isFile: () => true }); + } + return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + }); + + const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver'); + ClaudeBinaryResolver.clearCache(); + + await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary); + expect(statMock.mock.calls[0]?.[0]).toBe(expectedBinary); + }); + it('ignores the dedicated orchestrator overrides when Claude flavor is selected', async () => { process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-dev'; diff --git a/test/main/utils/windowsProcessTable.test.ts b/test/main/utils/windowsProcessTable.test.ts new file mode 100644 index 00000000..c45f22e4 --- /dev/null +++ b/test/main/utils/windowsProcessTable.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { parseWindowsProcessTableJson } from '../../../src/main/utils/windowsProcessTable'; + +describe('windowsProcessTable', () => { + it('parses PowerShell process table JSON objects and arrays', () => { + expect( + parseWindowsProcessTableJson( + JSON.stringify([ + { + ProcessId: 101, + ParentProcessId: 1, + CommandLine: 'node runtime --team-name demo --agent-id agent-a', + }, + { + ProcessId: '102', + ParentProcessId: '101', + CommandLine: 'opencode serve', + }, + { + ProcessId: 103, + ParentProcessId: 1, + CommandLine: null, + }, + ]) + ) + ).toEqual([ + { pid: 101, ppid: 1, command: 'node runtime --team-name demo --agent-id agent-a' }, + { pid: 102, ppid: 101, command: 'opencode serve' }, + ]); + + expect( + parseWindowsProcessTableJson( + JSON.stringify({ + ProcessId: 201, + ParentProcessId: 1, + CommandLine: 'claude --team-name demo --agent-id agent-b', + }) + ) + ).toEqual([{ pid: 201, ppid: 1, command: 'claude --team-name demo --agent-id agent-b' }]); + }); +}); From 2c3ee3e2e90f4e9db112501edc4e14fdd6e8f770 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 26 Apr 2026 12:04:11 +0300 Subject: [PATCH 3/3] fix(windows): stabilize ci path and wsl checks --- .../main/infrastructure/wsl/TmuxWslService.ts | 10 +++++++--- .../wsl/__tests__/TmuxWslService.test.ts | 3 ++- src/main/services/discovery/ProjectPathResolver.ts | 9 +++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts index b6ccfcda..a56ebea8 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts @@ -115,11 +115,14 @@ export class TmuxWslService { }; } - const distros = this.#parseWslDistros(distroListProbe.stdout); + const listedDistros = this.#parseWslDistros(distroListProbe.stdout); + const serviceDistros = listedDistros.filter((distro) => this.#isInternalWslDistro(distro)); + const distros = listedDistros.filter((distro) => !this.#isInternalWslDistro(distro)); if (distros.length === 0) { if (persistedPreferredDistro) { await this.#preferenceStore.clearPreferredDistro(); } + const hasOnlyServiceDistros = serviceDistros.length > 0; return { preference: null, status: { @@ -134,7 +137,9 @@ export class TmuxWslService { tmuxBinaryPath: null, statusDetail: rebootRequired ? 'WSL was installed, but Windows still needs a restart before a Linux distro can be configured.' - : 'WSL is available, but no Linux distribution is installed yet.', + : hasOnlyServiceDistros + ? `WSL has only service distributions (${serviceDistros.join(', ')}). Install a Linux distribution such as Ubuntu for teammate runtime support.` + : 'WSL is available, but no Linux distribution is installed yet.', }, }; } @@ -420,7 +425,6 @@ export class TmuxWslService { .split(/\r?\n/) .map((line) => line.replace(/\0/g, '').trim()) .map((line) => line.replace(/^\*\s*/, '').trim()) - .filter((line) => !this.#isInternalWslDistro(line)) .filter(Boolean); } diff --git a/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts index c65aa4a7..4b8f8ab1 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts @@ -162,7 +162,8 @@ describe('TmuxWslService', () => { expect(result.status.wslInstalled).toBe(true); expect(result.status.distroName).toBeNull(); - expect(result.status.statusDetail).toContain('no Linux distribution'); + expect(result.status.statusDetail).toContain('only service distributions'); + expect(result.status.statusDetail).toContain('docker-desktop'); }); it('switches preference source away from persisted after clearing a stale distro', async () => { diff --git a/src/main/services/discovery/ProjectPathResolver.ts b/src/main/services/discovery/ProjectPathResolver.ts index 161b56ba..a8376dc9 100644 --- a/src/main/services/discovery/ProjectPathResolver.ts +++ b/src/main/services/discovery/ProjectPathResolver.ts @@ -32,6 +32,11 @@ interface ResolveProjectPathOptions { forceRefresh?: boolean; } +function isAbsolutePathLike(value: string): boolean { + const slashPath = value.replace(/\\/g, '/'); + return path.isAbsolute(value) || /^[a-zA-Z]:\//.test(slashPath) || slashPath.startsWith('//'); +} + export class ProjectPathResolver { private readonly projectsDir: string; private readonly fsProvider: FileSystemProvider; @@ -66,7 +71,7 @@ export class ProjectPathResolver { } const cwdHint = opts.cwdHint?.trim(); - if (cwdHint && path.isAbsolute(cwdHint)) { + if (cwdHint && isAbsolutePathLike(cwdHint)) { this.projectPathCache.set(projectId, cwdHint); return cwdHint; } @@ -85,7 +90,7 @@ export class ProjectPathResolver { for (const sessionPath of sessionPaths.slice(0, maxPathsToInspect)) { try { const cwd = await extractCwd(sessionPath, this.fsProvider); - if (cwd && path.isAbsolute(cwd)) { + if (cwd && isAbsolutePathLike(cwd)) { this.projectPathCache.set(projectId, cwd); return cwd; }