merge: integrate runtime compatibility notices
This commit is contained in:
commit
17e1a448dc
33 changed files with 768 additions and 149 deletions
|
|
@ -13,6 +13,7 @@ import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildSyntheticRepositoryGroup,
|
buildSyntheticRepositoryGroup,
|
||||||
|
encodeProjectPathForNavigation,
|
||||||
findMatchingWorktree,
|
findMatchingWorktree,
|
||||||
type WorktreeMatch,
|
type WorktreeMatch,
|
||||||
} from '../utils/navigation';
|
} from '../utils/navigation';
|
||||||
|
|
@ -79,7 +80,7 @@ export function useOpenRecentProject(): {
|
||||||
repositoryGroups: [buildSyntheticRepositoryGroup(path), ...state.repositoryGroups],
|
repositoryGroups: [buildSyntheticRepositoryGroup(path), ...state.repositoryGroups],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const encodedId = path.replace(/[/\\]/g, '-');
|
const encodedId = encodeProjectPathForNavigation(path);
|
||||||
navigateToMatch({ repoId: encodedId, worktreeId: encodedId });
|
navigateToMatch({ repoId: encodedId, worktreeId: encodedId });
|
||||||
},
|
},
|
||||||
[fetchRepositoryGroups, navigateToMatch, repositoryGroups]
|
[fetchRepositoryGroups, navigateToMatch, repositoryGroups]
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,22 @@ export function findMatchingWorktree(
|
||||||
return null;
|
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 {
|
export function buildSyntheticRepositoryGroup(selectedPath: string): RepositoryGroup {
|
||||||
const encodedId = selectedPath.replace(/[/\\]/g, '-');
|
const encodedId = encodeProjectPathForNavigation(selectedPath);
|
||||||
const folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath;
|
const folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ describe('buildTmuxEffectiveAvailability', () => {
|
||||||
expect(result.runtimeReady).toBe(true);
|
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({
|
const result = buildTmuxEffectiveAvailability({
|
||||||
platform: 'win32',
|
platform: 'win32',
|
||||||
nativeSupported: false,
|
nativeSupported: false,
|
||||||
|
|
@ -47,7 +47,7 @@ describe('buildTmuxEffectiveAvailability', () => {
|
||||||
|
|
||||||
expect(result.available).toBe(true);
|
expect(result.available).toBe(true);
|
||||||
expect(result.location).toBe('wsl');
|
expect(result.location).toBe('wsl');
|
||||||
expect(result.runtimeReady).toBe(true);
|
expect(result.runtimeReady).toBe(false);
|
||||||
expect(result.version).toBe('tmux 3.4');
|
expect(result.version).toBe('tmux 3.4');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,9 @@ export function buildTmuxEffectiveAvailability(
|
||||||
location: 'wsl',
|
location: 'wsl',
|
||||||
version: input.wsl.tmuxVersion,
|
version: input.wsl.tmuxVersion,
|
||||||
binaryPath: input.wsl.tmuxBinaryPath,
|
binaryPath: input.wsl.tmuxBinaryPath,
|
||||||
runtimeReady: input.wsl.distroBootstrapped,
|
runtimeReady: false,
|
||||||
detail: input.wsl.distroBootstrapped
|
detail:
|
||||||
? 'tmux is available inside WSL for the persistent teammate runtime.'
|
'tmux is available inside WSL, but the persistent teammate runtime still needs native Windows pane support.',
|
||||||
: 'tmux is installed inside WSL, but the Linux distro still needs first-launch setup.',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,11 +127,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 (distros.length === 0) {
|
||||||
if (persistedPreferredDistro) {
|
if (persistedPreferredDistro) {
|
||||||
await this.#preferenceStore.clearPreferredDistro();
|
await this.#preferenceStore.clearPreferredDistro();
|
||||||
}
|
}
|
||||||
|
const hasOnlyServiceDistros = serviceDistros.length > 0;
|
||||||
return {
|
return {
|
||||||
preference: null,
|
preference: null,
|
||||||
status: {
|
status: {
|
||||||
|
|
@ -146,7 +149,9 @@ export class TmuxWslService {
|
||||||
tmuxBinaryPath: null,
|
tmuxBinaryPath: null,
|
||||||
statusDetail: rebootRequired
|
statusDetail: rebootRequired
|
||||||
? 'WSL was installed, but Windows still needs a restart before a Linux distro can be configured.'
|
? '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.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -461,6 +466,11 @@ export class TmuxWslService {
|
||||||
.filter(Boolean);
|
.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[] {
|
#parseVerboseDistroEntries(stdout: string, distros: string[]): WslVerboseDistroEntry[] {
|
||||||
const sortedDistros = [...distros].sort((left, right) => right.length - left.length);
|
const sortedDistros = [...distros].sort((left, right) => right.length - left.length);
|
||||||
const entries: WslVerboseDistroEntry[] = [];
|
const entries: WslVerboseDistroEntry[] = [];
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,23 @@ describe('TmuxWslService', () => {
|
||||||
expect(preferenceStore.getPreferredDistroSync()).toBeNull();
|
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('only service distributions');
|
||||||
|
expect(result.status.statusDetail).toContain('docker-desktop');
|
||||||
|
});
|
||||||
|
|
||||||
it('switches preference source away from persisted after clearing a stale distro', async () => {
|
it('switches preference source away from persisted after clearing a stale distro', async () => {
|
||||||
const preferenceStore = createPreferenceStore('Ubuntu');
|
const preferenceStore = createPreferenceStore('Ubuntu');
|
||||||
const service = new TmuxWslService(
|
const service = new TmuxWslService(
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ const logger = createLogger('HTTP:validation');
|
||||||
function isPathContained(fullPath: string, basePath: string): boolean {
|
function isPathContained(fullPath: string, basePath: string): boolean {
|
||||||
const normalizedFull = normalizeForContainment(fullPath);
|
const normalizedFull = normalizeForContainment(fullPath);
|
||||||
const normalizedBase = normalizeForContainment(basePath);
|
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 {
|
function normalizeForContainment(value: string): string {
|
||||||
|
|
@ -31,7 +32,9 @@ function normalizeForContainment(value: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProjectPath(projectPath: string, requestedPath: 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 {
|
export function registerValidationRoutes(app: FastifyInstance): void {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { isValidProjectId } from '@main/utils/pathDecoder';
|
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 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}$/;
|
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 };
|
return { valid: true, value: trimmed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rejectWindowsReserved(value: string, fieldName: string): ValidationResult<never> | null {
|
||||||
|
return isWindowsReservedFileName(value)
|
||||||
|
? { valid: false, error: `${fieldName} is reserved on Windows` }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateProjectId(projectId: unknown): ValidationResult<string> {
|
export function validateProjectId(projectId: unknown): ValidationResult<string> {
|
||||||
const basic = validateString(projectId, 'projectId');
|
const basic = validateString(projectId, 'projectId');
|
||||||
if (!basic.valid) {
|
if (!basic.valid) {
|
||||||
|
|
@ -126,6 +133,11 @@ export function validateTeamName(teamName: unknown): ValidationResult<string> {
|
||||||
return { valid: false, error: 'teamName contains invalid characters' };
|
return { valid: false, error: 'teamName contains invalid characters' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reserved = rejectWindowsReserved(basic.value!, 'teamName');
|
||||||
|
if (reserved) {
|
||||||
|
return reserved;
|
||||||
|
}
|
||||||
|
|
||||||
return { valid: true, value: basic.value };
|
return { valid: true, value: basic.value };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,6 +151,11 @@ export function validateTaskId(taskId: unknown): ValidationResult<string> {
|
||||||
return { valid: false, error: 'taskId contains invalid characters' };
|
return { valid: false, error: 'taskId contains invalid characters' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reserved = rejectWindowsReserved(basic.value!, 'taskId');
|
||||||
|
if (reserved) {
|
||||||
|
return reserved;
|
||||||
|
}
|
||||||
|
|
||||||
return { valid: true, value: basic.value };
|
return { valid: true, value: basic.value };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,6 +169,11 @@ export function validateMemberName(memberName: unknown): ValidationResult<string
|
||||||
return { valid: false, error: 'member contains invalid characters' };
|
return { valid: false, error: 'member contains invalid characters' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const windowsReserved = rejectWindowsReserved(basic.value!, 'member');
|
||||||
|
if (windowsReserved) {
|
||||||
|
return windowsReserved;
|
||||||
|
}
|
||||||
|
|
||||||
const lower = basic.value!.toLowerCase();
|
const lower = basic.value!.toLowerCase();
|
||||||
if (RESERVED_MEMBER_NAMES.has(lower)) {
|
if (RESERVED_MEMBER_NAMES.has(lower)) {
|
||||||
return { valid: false, error: `member name "${basic.value!}" is reserved` };
|
return { valid: false, error: `member name "${basic.value!}" is reserved` };
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,22 @@ export function removeValidationHandlers(ipcMain: IpcMain): void {
|
||||||
* Prevents path traversal attacks (e.g., ../../etc/passwd).
|
* Prevents path traversal attacks (e.g., ../../etc/passwd).
|
||||||
*/
|
*/
|
||||||
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);
|
||||||
|
const relative = path.relative(normalizedBase, normalizedFull);
|
||||||
|
|
||||||
// Ensure the full path starts with the base path followed by a separator
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||||
// or is exactly the base path
|
}
|
||||||
return normalizedFull === normalizedBase || normalizedFull.startsWith(normalizedBase + path.sep);
|
|
||||||
|
function normalizeForContainment(value: string): string {
|
||||||
|
const resolved = path.resolve(path.normalize(value));
|
||||||
|
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProjectPath(projectPath: string, requestedPath: string): string {
|
||||||
|
return path.isAbsolute(requestedPath)
|
||||||
|
? path.resolve(path.normalize(requestedPath))
|
||||||
|
: path.resolve(projectPath, requestedPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -67,7 +77,7 @@ async function handleValidatePath(
|
||||||
projectPath: string
|
projectPath: string
|
||||||
): Promise<{ exists: boolean; isDirectory?: boolean }> {
|
): Promise<{ exists: boolean; isDirectory?: boolean }> {
|
||||||
try {
|
try {
|
||||||
const fullPath = path.join(projectPath, relativePath);
|
const fullPath = resolveProjectPath(projectPath, relativePath);
|
||||||
|
|
||||||
// Security: Ensure path doesn't escape project directory
|
// Security: Ensure path doesn't escape project directory
|
||||||
if (!isPathContained(fullPath, projectPath)) {
|
if (!isPathContained(fullPath, projectPath)) {
|
||||||
|
|
@ -100,7 +110,7 @@ async function handleValidateMentions(
|
||||||
// (was sequential sync existsSync — blocked main thread per mention)
|
// (was sequential sync existsSync — blocked main thread per mention)
|
||||||
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);
|
||||||
|
|
||||||
// Security: Skip paths that escape project directory
|
// Security: Skip paths that escape project directory
|
||||||
if (!isPathContained(fullPath, projectPath)) {
|
if (!isPathContained(fullPath, projectPath)) {
|
||||||
|
|
|
||||||
|
|
@ -14,19 +14,19 @@ import {
|
||||||
type SemanticStepGroup,
|
type SemanticStepGroup,
|
||||||
type SubagentDetail,
|
type SubagentDetail,
|
||||||
} from '@main/types';
|
} from '@main/types';
|
||||||
import { extractBaseDir } from '@main/utils/pathDecoder';
|
|
||||||
import { countTokens } from '@main/utils/tokenizer';
|
import { countTokens } from '@main/utils/tokenizer';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const logger = createLogger('Service:SubagentDetailBuilder');
|
|
||||||
|
|
||||||
import { buildSemanticStepGroups } from './SemanticStepGrouper';
|
import { buildSemanticStepGroups } from './SemanticStepGrouper';
|
||||||
|
import { resolveProjectStorageDir } from '../discovery/projectStorageDir';
|
||||||
|
|
||||||
import type { SubagentResolver } from '../discovery/SubagentResolver';
|
import type { SubagentResolver } from '../discovery/SubagentResolver';
|
||||||
import type { FileSystemProvider } from '../infrastructure/FileSystemProvider';
|
import type { FileSystemProvider } from '../infrastructure/FileSystemProvider';
|
||||||
import type { SessionParser } from '../parsing/SessionParser';
|
import type { SessionParser } from '../parsing/SessionParser';
|
||||||
|
|
||||||
|
const logger = createLogger('Service:SubagentDetailBuilder');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build detailed information for a specific subagent.
|
* Build detailed information for a specific subagent.
|
||||||
* Used for drill-down modal to show subagent's internal execution.
|
* Used for drill-down modal to show subagent's internal execution.
|
||||||
|
|
@ -52,12 +52,14 @@ export async function buildSubagentDetail(
|
||||||
projectsDir: string
|
projectsDir: string
|
||||||
): Promise<SubagentDetail | null> {
|
): Promise<SubagentDetail | null> {
|
||||||
try {
|
try {
|
||||||
// Construct path to subagent JSONL file
|
const projectPath = await resolveProjectStorageDir(projectsDir, projectId, fsProvider);
|
||||||
// projectId may be composite (e.g. "baseDir::suffix"), extract base dir
|
if (!projectPath) {
|
||||||
const baseDir = extractBaseDir(projectId);
|
logger.warn(`Project storage directory not found for subagent detail: ${projectId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const subagentPath = path.join(
|
const subagentPath = path.join(
|
||||||
projectsDir,
|
projectPath,
|
||||||
baseDir,
|
|
||||||
sessionId,
|
sessionId,
|
||||||
'subagents',
|
'subagents',
|
||||||
`agent-${subagentId}.jsonl`
|
`agent-${subagentId}.jsonl`
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,12 @@
|
||||||
|
|
||||||
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
|
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
|
||||||
import { extractCwd } from '@main/utils/jsonl';
|
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 { createLogger } from '@shared/utils/logger';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
|
@ -27,6 +32,11 @@ interface ResolveProjectPathOptions {
|
||||||
forceRefresh?: boolean;
|
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 {
|
export class ProjectPathResolver {
|
||||||
private readonly projectsDir: string;
|
private readonly projectsDir: string;
|
||||||
private readonly fsProvider: FileSystemProvider;
|
private readonly fsProvider: FileSystemProvider;
|
||||||
|
|
@ -61,7 +71,7 @@ export class ProjectPathResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
const cwdHint = opts.cwdHint?.trim();
|
const cwdHint = opts.cwdHint?.trim();
|
||||||
if (cwdHint && path.isAbsolute(cwdHint)) {
|
if (cwdHint && isAbsolutePathLike(cwdHint)) {
|
||||||
this.projectPathCache.set(projectId, cwdHint);
|
this.projectPathCache.set(projectId, cwdHint);
|
||||||
return cwdHint;
|
return cwdHint;
|
||||||
}
|
}
|
||||||
|
|
@ -80,7 +90,7 @@ export class ProjectPathResolver {
|
||||||
for (const sessionPath of sessionPaths.slice(0, maxPathsToInspect)) {
|
for (const sessionPath of sessionPaths.slice(0, maxPathsToInspect)) {
|
||||||
try {
|
try {
|
||||||
const cwd = await extractCwd(sessionPath, this.fsProvider);
|
const cwd = await extractCwd(sessionPath, this.fsProvider);
|
||||||
if (cwd && path.isAbsolute(cwd)) {
|
if (cwd && isAbsolutePathLike(cwd)) {
|
||||||
this.projectPathCache.set(projectId, cwd);
|
this.projectPathCache.set(projectId, cwd);
|
||||||
return cwd;
|
return cwd;
|
||||||
}
|
}
|
||||||
|
|
@ -109,20 +119,24 @@ export class ProjectPathResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listSessionPaths(projectId: string): Promise<string[]> {
|
private async listSessionPaths(projectId: string): Promise<string[]> {
|
||||||
const projectDir = path.join(this.projectsDir, extractBaseDir(projectId));
|
for (const dirName of getProjectDirNameCandidates(projectId)) {
|
||||||
if (!(await this.fsProvider.exists(projectDir))) {
|
const projectDir = path.join(this.projectsDir, dirName);
|
||||||
return [];
|
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 {
|
return [];
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ import { configManager } from '../infrastructure/ConfigManager';
|
||||||
import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider';
|
import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider';
|
||||||
|
|
||||||
import { ProjectPathResolver } from './ProjectPathResolver';
|
import { ProjectPathResolver } from './ProjectPathResolver';
|
||||||
|
import { resolveProjectStorageDir as resolveProjectStorageDirFromCandidates } from './projectStorageDir';
|
||||||
import { SessionContentFilter } from './SessionContentFilter';
|
import { SessionContentFilter } from './SessionContentFilter';
|
||||||
import { SessionSearcher } from './SessionSearcher';
|
import { SessionSearcher } from './SessionSearcher';
|
||||||
import { SubagentLocator } from './SubagentLocator';
|
import { SubagentLocator } from './SubagentLocator';
|
||||||
|
|
@ -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.
|
// for lookups and navigation; a small cap preserves that behavior without huge payloads.
|
||||||
const MAX_SESSION_IDS_EXPORTED = 200;
|
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.
|
* Fast, zero-I/O worktree detection based on path patterns only.
|
||||||
* Used by scanWithWorktreeGrouping to provide accurate worktree metadata
|
* Used by scanWithWorktreeGrouping to provide accurate worktree metadata
|
||||||
|
|
@ -85,7 +90,7 @@ function detectWorktreeFromPath(projectPath: string): {
|
||||||
isWorktree: boolean;
|
isWorktree: boolean;
|
||||||
source: WorktreeSource;
|
source: WorktreeSource;
|
||||||
} {
|
} {
|
||||||
const parts = projectPath.split(path.sep).filter(Boolean);
|
const parts = splitPathSegments(projectPath);
|
||||||
|
|
||||||
if (parts.includes(VIBE_KANBAN_DIR) && parts.includes(WORKTREES_DIR)) {
|
if (parts.includes(VIBE_KANBAN_DIR) && parts.includes(WORKTREES_DIR)) {
|
||||||
return { isWorktree: true, source: 'vibe-kanban' };
|
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.
|
* Handles composite IDs by scanning the base directory and finding the matching subproject.
|
||||||
*/
|
*/
|
||||||
async getProject(projectId: string): Promise<Project | null> {
|
async getProject(projectId: string): Promise<Project | null> {
|
||||||
const baseDir = extractBaseDir(projectId);
|
const projectPath = await this.resolveProjectStorageDir(projectId);
|
||||||
const projectPath = path.join(this.projectsDir, baseDir);
|
|
||||||
|
|
||||||
if (!(await this.fsProvider.exists(projectPath))) {
|
if (!projectPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const baseDir = path.basename(projectPath);
|
||||||
|
|
||||||
// For composite IDs, scan and find the matching subproject
|
// For composite IDs, scan and find the matching subproject
|
||||||
if (subprojectRegistry.isComposite(projectId)) {
|
if (subprojectRegistry.isComposite(projectId)) {
|
||||||
|
|
@ -628,13 +633,12 @@ export class ProjectScanner {
|
||||||
*/
|
*/
|
||||||
async listSessions(projectId: string): Promise<Session[]> {
|
async listSessions(projectId: string): Promise<Session[]> {
|
||||||
try {
|
try {
|
||||||
const baseDir = extractBaseDir(projectId);
|
const projectPath = await this.resolveProjectStorageDir(projectId);
|
||||||
const projectPath = path.join(this.projectsDir, baseDir);
|
|
||||||
const sessionFilter = await this.getSessionFilterForProject(projectId);
|
const sessionFilter = await this.getSessionFilterForProject(projectId);
|
||||||
const shouldFilterNoise = this.fsProvider.type !== 'ssh';
|
const shouldFilterNoise = this.fsProvider.type !== 'ssh';
|
||||||
const metadataLevel: SessionMetadataLevel = this.fsProvider.type === 'ssh' ? 'light' : 'deep';
|
const metadataLevel: SessionMetadataLevel = this.fsProvider.type === 'ssh' ? 'light' : 'deep';
|
||||||
|
|
||||||
if (!(await this.fsProvider.exists(projectPath))) {
|
if (!projectPath) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -717,14 +721,13 @@ export class ProjectScanner {
|
||||||
try {
|
try {
|
||||||
const includeTotalCount = options?.includeTotalCount ?? false;
|
const includeTotalCount = options?.includeTotalCount ?? false;
|
||||||
const prefilterAll = options?.prefilterAll ?? false;
|
const prefilterAll = options?.prefilterAll ?? false;
|
||||||
const baseDir = extractBaseDir(projectId);
|
const projectPath = await this.resolveProjectStorageDir(projectId);
|
||||||
const projectPath = path.join(this.projectsDir, baseDir);
|
|
||||||
const sessionFilter = await this.getSessionFilterForProject(projectId);
|
const sessionFilter = await this.getSessionFilterForProject(projectId);
|
||||||
const shouldFilterNoise = this.fsProvider.type !== 'ssh';
|
const shouldFilterNoise = this.fsProvider.type !== 'ssh';
|
||||||
const metadataLevel: SessionMetadataLevel =
|
const metadataLevel: SessionMetadataLevel =
|
||||||
options?.metadataLevel ?? (this.fsProvider.type === 'ssh' ? 'light' : 'deep');
|
options?.metadataLevel ?? (this.fsProvider.type === 'ssh' ? 'light' : 'deep');
|
||||||
|
|
||||||
if (!(await this.fsProvider.exists(projectPath))) {
|
if (!projectPath) {
|
||||||
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
|
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1135,9 +1138,9 @@ export class ProjectScanner {
|
||||||
* Gets a single session's metadata.
|
* Gets a single session's metadata.
|
||||||
*/
|
*/
|
||||||
async getSession(projectId: string, sessionId: string): Promise<Session | null> {
|
async getSession(projectId: string, sessionId: string): Promise<Session | null> {
|
||||||
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1154,9 +1157,9 @@ export class ProjectScanner {
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
options?: SessionsByIdsOptions
|
options?: SessionsByIdsOptions
|
||||||
): Promise<Session | null> {
|
): Promise<Session | null> {
|
||||||
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1202,6 +1205,14 @@ export class ProjectScanner {
|
||||||
return buildSessionPath(this.projectsDir, projectId, sessionId);
|
return buildSessionPath(this.projectsDir, projectId, sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a session path using all known project storage directory codecs.
|
||||||
|
*/
|
||||||
|
async resolveSessionPath(projectId: string, sessionId: string): Promise<string | null> {
|
||||||
|
const projectPath = await this.resolveProjectStorageDir(projectId);
|
||||||
|
return projectPath ? path.join(projectPath, `${sessionId}.jsonl`) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the path to the subagents directory.
|
* Gets the path to the subagents directory.
|
||||||
*/
|
*/
|
||||||
|
|
@ -1214,11 +1225,10 @@ export class ProjectScanner {
|
||||||
*/
|
*/
|
||||||
async listSessionFiles(projectId: string): Promise<string[]> {
|
async listSessionFiles(projectId: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const baseDir = extractBaseDir(projectId);
|
const projectPath = await this.resolveProjectStorageDir(projectId);
|
||||||
const projectPath = path.join(this.projectsDir, baseDir);
|
|
||||||
const sessionFilter = await this.getSessionFilterForProject(projectId);
|
const sessionFilter = await this.getSessionFilterForProject(projectId);
|
||||||
|
|
||||||
if (!(await this.fsProvider.exists(projectPath))) {
|
if (!projectPath) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1237,6 +1247,10 @@ export class ProjectScanner {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveProjectStorageDir(projectId: string): Promise<string | null> {
|
||||||
|
return resolveProjectStorageDirFromCandidates(this.projectsDir, projectId, this.fsProvider);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the session filter set for a project.
|
* Returns the session filter set for a project.
|
||||||
* In local mode, composite IDs are refreshed from disk first so newly created
|
* In local mode, composite IDs are refreshed from disk first so newly created
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
|
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
|
||||||
import { parseJsonlFile } from '@main/utils/jsonl';
|
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 { createLogger } from '@shared/utils/logger';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { startMainSpan } from '../../sentry';
|
||||||
|
|
||||||
import { SearchTextCache } from './SearchTextCache';
|
import { SearchTextCache } from './SearchTextCache';
|
||||||
import { extractSearchableEntries } from './SearchTextExtractor';
|
import { extractSearchableEntries } from './SearchTextExtractor';
|
||||||
|
import { resolveProjectStorageDir } from './projectStorageDir';
|
||||||
import { subprojectRegistry } from './SubprojectRegistry';
|
import { subprojectRegistry } from './SubprojectRegistry';
|
||||||
|
|
||||||
import type { SearchableEntry } from './SearchTextExtractor';
|
import type { SearchableEntry } from './SearchTextExtractor';
|
||||||
|
|
@ -74,11 +75,14 @@ export class SessionSearcher {
|
||||||
const normalizedQuery = query.toLowerCase().trim();
|
const normalizedQuery = query.toLowerCase().trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const baseDir = extractBaseDir(projectId);
|
const projectPath = await resolveProjectStorageDir(
|
||||||
const projectPath = path.join(this.projectsDir, baseDir);
|
this.projectsDir,
|
||||||
|
projectId,
|
||||||
|
this.fsProvider
|
||||||
|
);
|
||||||
const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
|
const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
|
||||||
|
|
||||||
if (!(await this.fsProvider.exists(projectPath))) {
|
if (!projectPath) {
|
||||||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -283,9 +287,7 @@ export class SessionSearcher {
|
||||||
sessionTitle: sessionTitle ?? 'Untitled Session',
|
sessionTitle: sessionTitle ?? 'Untitled Session',
|
||||||
matchedText,
|
matchedText,
|
||||||
context:
|
context:
|
||||||
(contextStart > 0 ? '...' : '') +
|
(contextStart > 0 ? '...' : '') + context + (contextEnd < entry.text.length ? '...' : ''),
|
||||||
context +
|
|
||||||
(contextEnd < entry.text.length ? '...' : ''),
|
|
||||||
messageType: entry.messageType,
|
messageType: entry.messageType,
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
groupId: entry.groupId,
|
groupId: entry.groupId,
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
|
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 { createLogger } from '@shared/utils/logger';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { resolveProjectStorageDir, resolveProjectStorageDirSync } from './projectStorageDir';
|
||||||
|
|
||||||
import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider';
|
import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider';
|
||||||
|
|
||||||
const logger = createLogger('Discovery:SubagentLocator');
|
const logger = createLogger('Discovery:SubagentLocator');
|
||||||
|
|
@ -40,7 +42,10 @@ export class SubagentLocator {
|
||||||
*/
|
*/
|
||||||
async hasSubagents(projectId: string, sessionId: string): Promise<boolean> {
|
async hasSubagents(projectId: string, sessionId: string): Promise<boolean> {
|
||||||
// Check NEW structure: {projectId}/{sessionId}/subagents/
|
// Check NEW structure: {projectId}/{sessionId}/subagents/
|
||||||
const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
|
const newSubagentsPath = await this.resolveSubagentsPath(projectId, sessionId);
|
||||||
|
if (!newSubagentsPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const entries = await this.fsProvider.readdir(newSubagentsPath);
|
const entries = await this.fsProvider.readdir(newSubagentsPath);
|
||||||
// A non-empty agent-*.jsonl file is sufficient proof of subagents.
|
// 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<string[]> {
|
async listSubagentFiles(projectId: string, sessionId: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
|
const newSubagentsPath = await this.resolveSubagentsPath(projectId, sessionId);
|
||||||
|
if (!newSubagentsPath) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
if (await this.fsProvider.exists(newSubagentsPath)) {
|
if (await this.fsProvider.exists(newSubagentsPath)) {
|
||||||
const entries = await this.fsProvider.readdir(newSubagentsPath);
|
const entries = await this.fsProvider.readdir(newSubagentsPath);
|
||||||
return entries
|
return entries
|
||||||
|
|
@ -118,6 +126,18 @@ export class SubagentLocator {
|
||||||
* @returns Path to the subagents directory
|
* @returns Path to the subagents directory
|
||||||
*/
|
*/
|
||||||
getSubagentsPath(projectId: string, sessionId: string): string {
|
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<string | null> {
|
||||||
|
const projectPath = await resolveProjectStorageDir(
|
||||||
|
this.projectsDir,
|
||||||
|
projectId,
|
||||||
|
this.fsProvider
|
||||||
|
);
|
||||||
|
return projectPath ? path.join(projectPath, sessionId, 'subagents') : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
32
src/main/services/discovery/projectStorageDir.ts
Normal file
32
src/main/services/discovery/projectStorageDir.ts
Normal file
|
|
@ -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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -547,7 +547,7 @@ export class ProjectFileService {
|
||||||
const newPath = path.join(normalizedDest, path.basename(normalizedSrc));
|
const newPath = path.join(normalizedDest, path.basename(normalizedSrc));
|
||||||
|
|
||||||
// 8. Prevent parent → child move (moving dir into itself)
|
// 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');
|
throw new Error('Cannot move a directory into itself');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,11 +77,16 @@ export class SkillsCatalogService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private isWithinRoot(targetPath: string, rootPath: string): boolean {
|
private isWithinRoot(targetPath: string, rootPath: string): boolean {
|
||||||
const normalizedTarget = path.resolve(targetPath);
|
const normalizedTarget = this.normalizeForContainment(targetPath);
|
||||||
const normalizedRoot = path.resolve(rootPath);
|
const normalizedRoot = this.normalizeForContainment(rootPath);
|
||||||
|
const relativePath = path.relative(normalizedRoot, normalizedTarget);
|
||||||
return (
|
return (
|
||||||
normalizedTarget === normalizedRoot ||
|
relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))
|
||||||
normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeForContainment(value: string): string {
|
||||||
|
const resolved = path.resolve(path.normalize(value));
|
||||||
|
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,10 @@ async function fileExists(filePath: string): Promise<boolean> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitPathSegments(value: string): string[] {
|
||||||
|
return value.split(/[/\\]+/).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
class GitIdentityResolver {
|
class GitIdentityResolver {
|
||||||
private identityCache = new Map<string, CacheEntry<RepositoryIdentity | null>>();
|
private identityCache = new Map<string, CacheEntry<RepositoryIdentity | null>>();
|
||||||
private branchCache = new Map<string, CacheEntry<string | null>>();
|
private branchCache = new Map<string, CacheEntry<string | null>>();
|
||||||
|
|
@ -195,7 +199,7 @@ class GitIdentityResolver {
|
||||||
* - Default: last path component
|
* - Default: last path component
|
||||||
*/
|
*/
|
||||||
private extractRepoNameFromPath(projectPath: string): string | null {
|
private extractRepoNameFromPath(projectPath: string): string | null {
|
||||||
const parts = projectPath.split(path.sep).filter(Boolean);
|
const parts = splitPathSegments(projectPath);
|
||||||
|
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -271,7 +275,7 @@ class GitIdentityResolver {
|
||||||
*/
|
*/
|
||||||
async isWorktree(projectPath: string): Promise<boolean> {
|
async isWorktree(projectPath: string): Promise<boolean> {
|
||||||
// First, try path-based heuristics (works for deleted worktrees)
|
// 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
|
// Check for known worktree patterns - these are ALWAYS worktrees
|
||||||
if (parts.includes(CURSOR_DIR) && parts.includes(WORKTREES_DIR)) {
|
if (parts.includes(CURSOR_DIR) && parts.includes(WORKTREES_DIR)) {
|
||||||
|
|
@ -326,7 +330,7 @@ class GitIdentityResolver {
|
||||||
private extractMainGitDir(worktreeGitDir: string): string {
|
private extractMainGitDir(worktreeGitDir: string): string {
|
||||||
// worktreeGitDir is typically: /path/to/main/.git/worktrees/<worktree-name>
|
// worktreeGitDir is typically: /path/to/main/.git/worktrees/<worktree-name>
|
||||||
// We need to go up two levels to get to .git
|
// 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);
|
const worktreesIndex = parts.lastIndexOf(WORKTREES_DIR);
|
||||||
|
|
||||||
if (worktreesIndex > 0) {
|
if (worktreesIndex > 0) {
|
||||||
|
|
@ -551,7 +555,7 @@ class GitIdentityResolver {
|
||||||
* @returns WorktreeSource identifier
|
* @returns WorktreeSource identifier
|
||||||
*/
|
*/
|
||||||
async detectWorktreeSource(projectPath: string): Promise<WorktreeSource> {
|
async detectWorktreeSource(projectPath: string): Promise<WorktreeSource> {
|
||||||
const parts = projectPath.split(path.sep).filter(Boolean);
|
const parts = splitPathSegments(projectPath);
|
||||||
|
|
||||||
// Pattern: vibe-kanban
|
// Pattern: vibe-kanban
|
||||||
// /tmp/vibe-kanban/worktrees/{issue-branch}/{repo}
|
// /tmp/vibe-kanban/worktrees/{issue-branch}/{repo}
|
||||||
|
|
@ -626,7 +630,7 @@ class GitIdentityResolver {
|
||||||
branch: string | null,
|
branch: string | null,
|
||||||
isMainWorktree: boolean
|
isMainWorktree: boolean
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const parts = projectPath.split(path.sep).filter(Boolean);
|
const parts = splitPathSegments(projectPath);
|
||||||
|
|
||||||
switch (source) {
|
switch (source) {
|
||||||
case 'vibe-kanban': {
|
case 'vibe-kanban': {
|
||||||
|
|
@ -754,7 +758,7 @@ class GitIdentityResolver {
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
// gitdir: /main/.git/worktrees/my-worktree-name
|
// 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);
|
const worktreesIdx = gitdirParts.lastIndexOf(WORKTREES_DIR);
|
||||||
if (worktreesIdx >= 0 && gitdirParts[worktreesIdx + 1]) {
|
if (worktreesIdx >= 0 && gitdirParts[worktreesIdx + 1]) {
|
||||||
return gitdirParts[worktreesIdx + 1];
|
return gitdirParts[worktreesIdx + 1];
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,9 @@ export class SessionParser {
|
||||||
* Parse a session JSONL file and return structured data.
|
* Parse a session JSONL file and return structured data.
|
||||||
*/
|
*/
|
||||||
async parseSession(projectId: string, sessionId: string): Promise<ParsedSession> {
|
async parseSession(projectId: string, sessionId: string): Promise<ParsedSession> {
|
||||||
const sessionPath = this.projectScanner.getSessionPath(projectId, sessionId);
|
const sessionPath =
|
||||||
|
(await this.projectScanner.resolveSessionPath(projectId, sessionId)) ??
|
||||||
|
this.projectScanner.getSessionPath(projectId, sessionId);
|
||||||
return this.parseSessionFile(sessionPath);
|
return this.parseSessionFile(sessionPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -147,25 +147,19 @@ async function resolveFromExplicitPath(inputPath: string): Promise<string | null
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'win32' && !path.extname(trimmed)) {
|
||||||
|
for (const ext of getWindowsExecutableExtensions()) {
|
||||||
|
const candidate = `${trimmed}${ext}`;
|
||||||
|
if (await isExecutable(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (await isExecutable(trimmed)) {
|
if (await isExecutable(trimmed)) {
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.extname(trimmed)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const ext of getWindowsExecutableExtensions()) {
|
|
||||||
const candidate = `${trimmed}${ext}`;
|
|
||||||
if (await isExecutable(candidate)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -378,7 +378,10 @@ export class FileContentResolver {
|
||||||
* For subagents, sessionId = the parent directory's parent name.
|
* For subagents, sessionId = the parent directory's parent name.
|
||||||
*/
|
*/
|
||||||
private extractSessionId(logPath: string): string | null {
|
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
|
// Check if it's a subagent path: .../{sessionId}/subagents/agent-xxx.jsonl
|
||||||
const subagentsIdx = parts.indexOf('subagents');
|
const subagentsIdx = parts.indexOf('subagents');
|
||||||
|
|
@ -613,7 +616,7 @@ export class FileContentResolver {
|
||||||
|
|
||||||
private getDisplayRelativePath(filePath: string, segmentCount: number): string {
|
private getDisplayRelativePath(filePath: string, segmentCount: number): string {
|
||||||
const normalized = path.normalize(filePath);
|
const normalized = path.normalize(filePath);
|
||||||
const parts = normalized.split(path.sep).filter(Boolean);
|
const parts = normalized.split(/[/\\]+/).filter(Boolean);
|
||||||
return parts.slice(-segmentCount).join('/');
|
return parts.slice(-segmentCount).join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,16 +52,13 @@ function isOpaqueSafeTaskIdSegment(segment: string): boolean {
|
||||||
return /^task-id-[0-9a-f]{32}$/.test(segment);
|
return /^task-id-[0-9a-f]{32}$/.test(segment);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldIgnoreLogSourceWatcherPath(
|
export function shouldIgnoreLogSourceWatcherPath(projectDir: string, watchedPath: string): boolean {
|
||||||
projectDir: string,
|
|
||||||
watchedPath: string
|
|
||||||
): boolean {
|
|
||||||
const relativePath = path.relative(projectDir, watchedPath);
|
const relativePath = path.relative(projectDir, watchedPath);
|
||||||
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = relativePath.split(path.sep).filter(Boolean);
|
const parts = relativePath.split(/[/\\]+/).filter(Boolean);
|
||||||
return parts[0] === BOARD_TASK_CHANGES_DIRNAME;
|
return parts[0] === BOARD_TASK_CHANGES_DIRNAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -399,9 +396,7 @@ export class TeamLogSourceTracker {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const taskId = decodeURIComponent(encodedTaskId);
|
const taskId = decodeURIComponent(encodedTaskId);
|
||||||
return taskId.trim().length > 0
|
return taskId.trim().length > 0 ? { kind: 'task-id', taskId } : { kind: 'invalid' };
|
||||||
? { kind: 'task-id', taskId }
|
|
||||||
: { kind: 'invalid' };
|
|
||||||
} catch {
|
} catch {
|
||||||
return { kind: 'invalid' };
|
return { kind: 'invalid' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,10 @@ import { isProcessAlive } from '@main/utils/processHealth';
|
||||||
import { killProcessByPid } from '@main/utils/processKill';
|
import { killProcessByPid } from '@main/utils/processKill';
|
||||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||||
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
|
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
|
||||||
|
import {
|
||||||
|
listWindowsProcessTable,
|
||||||
|
listWindowsProcessTableSync,
|
||||||
|
} from '@main/utils/windowsProcessTable';
|
||||||
import {
|
import {
|
||||||
AGENT_BLOCK_CLOSE,
|
AGENT_BLOCK_CLOSE,
|
||||||
AGENT_BLOCK_OPEN,
|
AGENT_BLOCK_OPEN,
|
||||||
|
|
@ -203,6 +207,7 @@ import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
import { TeamMetaStore } from './TeamMetaStore';
|
import { TeamMetaStore } from './TeamMetaStore';
|
||||||
import {
|
import {
|
||||||
|
commandArgEquals,
|
||||||
isStrongRuntimeEvidence,
|
isStrongRuntimeEvidence,
|
||||||
resolveTeamMemberRuntimeLiveness,
|
resolveTeamMemberRuntimeLiveness,
|
||||||
sanitizeProcessCommandForDiagnostics,
|
sanitizeProcessCommandForDiagnostics,
|
||||||
|
|
@ -15191,6 +15196,25 @@ export class TeamProvisioningService {
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let windowsHostProcessRows: typeof processRows | null = null;
|
||||||
|
let windowsHostProcessTableAvailable = false;
|
||||||
|
const getWindowsHostProcessRows = async (): Promise<typeof processRows> => {
|
||||||
|
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()) {
|
for (const [memberName, metadata] of metadataByMember.entries()) {
|
||||||
const paneId = metadata.tmuxPaneId?.trim() ?? '';
|
const paneId = metadata.tmuxPaneId?.trim() ?? '';
|
||||||
|
|
@ -15225,6 +15249,20 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const status = this.findTrackedMemberSpawnStatus(run, memberName) ?? adapterStatus;
|
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({
|
const resolved = resolveTeamMemberRuntimeLiveness({
|
||||||
teamName,
|
teamName,
|
||||||
memberName,
|
memberName,
|
||||||
|
|
@ -15238,8 +15276,8 @@ export class TeamProvisioningService {
|
||||||
runtimePid: metadata.metricsPid,
|
runtimePid: metadata.metricsPid,
|
||||||
runtimeSessionId: metadata.runtimeSessionId,
|
runtimeSessionId: metadata.runtimeSessionId,
|
||||||
pane: paneId ? paneInfoById.get(paneId) : undefined,
|
pane: paneId ? paneInfoById.get(paneId) : undefined,
|
||||||
processRows,
|
processRows: memberProcessRows,
|
||||||
processTableAvailable,
|
processTableAvailable: memberProcessTableAvailable,
|
||||||
nowIso: nowIso(),
|
nowIso: nowIso(),
|
||||||
});
|
});
|
||||||
metadataByMember.set(memberName, {
|
metadataByMember.set(memberName, {
|
||||||
|
|
@ -17757,37 +17795,50 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private killOrphanedTeamAgentProcesses(teamName: string): void {
|
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)
|
const currentRunPid = this.getTrackedRunId(teamName)
|
||||||
? this.runs.get(this.getTrackedRunId(teamName)!)?.child?.pid
|
? this.runs.get(this.getTrackedRunId(teamName)!)?.child?.pid
|
||||||
: undefined;
|
: undefined;
|
||||||
const marker = `--team-name ${teamName}`;
|
|
||||||
const pids = new Set<number>();
|
const pids = new Set<number>();
|
||||||
|
const rows: Array<{ pid: number; command: string }> = [];
|
||||||
|
|
||||||
for (const line of output.split('\n')) {
|
if (process.platform === 'win32') {
|
||||||
const trimmed = line.trim();
|
try {
|
||||||
if (!trimmed || !trimmed.includes(marker) || !trimmed.includes('--agent-id')) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
const match = /^(\d+)\s+(.*)$/.exec(trimmed);
|
if (currentRunPid && row.pid === currentRunPid) continue;
|
||||||
if (!match) continue;
|
pids.add(row.pid);
|
||||||
const pid = Number.parseInt(match[1], 10);
|
|
||||||
if (!Number.isFinite(pid) || pid <= 0) continue;
|
|
||||||
if (currentRunPid && pid === currentRunPid) continue;
|
|
||||||
pids.add(pid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const pid of pids) {
|
for (const pid of pids) {
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,30 @@ export function encodePath(absolutePath: string): string {
|
||||||
return encoded.startsWith('-') ? encoded : `-${encoded}`;
|
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.
|
* Decodes a project directory name to its original path.
|
||||||
* Note: This is a best-effort decode. Paths with dashes cannot be decoded accurately.
|
* 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"
|
// Support legacy Windows format: "C--Users-name-project"
|
||||||
// (no leading dash, drive separator encoded as "--").
|
// (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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,11 +166,10 @@ export function isValidEncodedPath(encodedName: string): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow only expected encoded characters:
|
// Encoded path is a single directory name. It may contain Unicode project
|
||||||
// - alphanumeric, underscores, dots, spaces, dashes
|
// names, but must not contain separators, control chars, or Windows-invalid chars.
|
||||||
// - optional ":" for Windows drive notation (e.g., -C:-Users-name-project)
|
// A single drive colon is allowed only in the old "-C:-Users-name" form.
|
||||||
const validPattern = /^-[a-zA-Z0-9_.\s:-]+$/;
|
if (/[\x00-\x1f/\\*?"<>|]/u.test(encodedName)) {
|
||||||
if (!validPattern.test(encodedName)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,6 +225,35 @@ export function extractBaseDir(projectId: string): string {
|
||||||
return projectId;
|
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
|
// Session ID Extraction
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,11 @@ function normalizeForCompare(input: string, isWindows: boolean): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPathWithinRoot(targetPath: string, rootPath: string): boolean {
|
export function isPathWithinRoot(targetPath: string, rootPath: string): boolean {
|
||||||
const target = path.resolve(targetPath);
|
const isWindows = process.platform === 'win32';
|
||||||
const root = path.resolve(rootPath);
|
const target = normalizeForCompare(targetPath, isWindows);
|
||||||
return target === root || target.startsWith(root + path.sep);
|
const root = normalizeForCompare(rootPath, isWindows);
|
||||||
|
const relative = path.relative(root, target);
|
||||||
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRealPathIfExists(inputPath: string): string | null {
|
function resolveRealPathIfExists(inputPath: string): string | null {
|
||||||
|
|
@ -317,6 +319,47 @@ const MAX_FILENAME_LENGTH = 255;
|
||||||
/** Characters forbidden in file/directory names. */
|
/** Characters forbidden in file/directory names. */
|
||||||
// eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- Intentional: validating filenames against control characters
|
// eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- Intentional: validating filenames against control characters
|
||||||
const INVALID_FILENAME_CHARS = /[\x00-\x1f/\\:*?"<>|]/;
|
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.
|
* 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' };
|
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 };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
105
src/main/utils/windowsProcessTable.ts
Normal file
105
src/main/utils/windowsProcessTable.ts
Normal file
|
|
@ -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<WindowsProcessTableRow[]> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildSyntheticRepositoryGroup,
|
buildSyntheticRepositoryGroup,
|
||||||
|
encodeProjectPathForNavigation,
|
||||||
findMatchingWorktree,
|
findMatchingWorktree,
|
||||||
} from '@features/recent-projects/renderer/utils/navigation';
|
} from '@features/recent-projects/renderer/utils/navigation';
|
||||||
|
|
||||||
|
|
@ -31,9 +32,7 @@ describe('recent-projects navigation utils', () => {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(
|
expect(findMatchingWorktree(groups, ['/users/test/alpha/', '/users/test/other'])).toEqual({
|
||||||
findMatchingWorktree(groups, ['/users/test/alpha/', '/users/test/other'])
|
|
||||||
).toEqual({
|
|
||||||
repoId: 'repo-alpha',
|
repoId: 'repo-alpha',
|
||||||
worktreeId: 'wt-alpha',
|
worktreeId: 'wt-alpha',
|
||||||
});
|
});
|
||||||
|
|
@ -59,4 +58,10 @@ describe('recent-projects navigation utils', () => {
|
||||||
|
|
||||||
vi.useRealTimers();
|
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'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -75,4 +75,11 @@ describe('ipc guards', () => {
|
||||||
expect(validateMemberName('alice bob').valid).toBe(false);
|
expect(validateMemberName('alice bob').valid).toBe(false);
|
||||||
expect(validateFromField('../../etc').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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ import * as path from 'path';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { ProjectScanner } from '../../../../src/main/services/discovery/ProjectScanner';
|
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 { 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 {
|
function createSessionLine(opts: { cwd?: string; type?: string }): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
|
|
@ -50,10 +53,7 @@ describe('ProjectScanner cwd split logic', () => {
|
||||||
// Session WITHOUT cwd (older format)
|
// Session WITHOUT cwd (older format)
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(projectDir, 'session-no-cwd.jsonl'),
|
path.join(projectDir, 'session-no-cwd.jsonl'),
|
||||||
createSessionLine({ type: 'system' }) +
|
createSessionLine({ type: 'system' }) + '\n' + createSessionLine({ type: 'user' }) + '\n'
|
||||||
'\n' +
|
|
||||||
createSessionLine({ type: 'user' }) +
|
|
||||||
'\n'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const scanner = new ProjectScanner(projectsDir);
|
const scanner = new ProjectScanner(projectsDir);
|
||||||
|
|
@ -102,4 +102,55 @@ describe('ProjectScanner cwd split logic', () => {
|
||||||
expect(proj.id).toContain('::');
|
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]);
|
||||||
|
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 () => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ describe('ClaudeBinaryResolver', () => {
|
||||||
const originalPlatform = process.platform;
|
const originalPlatform = process.platform;
|
||||||
const originalCwd = process.cwd;
|
const originalCwd = process.cwd;
|
||||||
const originalResourcesPath = process.resourcesPath;
|
const originalResourcesPath = process.resourcesPath;
|
||||||
|
const originalPathext = process.env.PATHEXT;
|
||||||
const workspaceRoot = '/Users/belief/dev/projects/claude/claude_team_runtime';
|
const workspaceRoot = '/Users/belief/dev/projects/claude/claude_team_runtime';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -91,6 +92,11 @@ describe('ClaudeBinaryResolver', () => {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
if (originalPathext === undefined) {
|
||||||
|
delete process.env.PATHEXT;
|
||||||
|
} else {
|
||||||
|
process.env.PATHEXT = originalPathext;
|
||||||
|
}
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -130,6 +136,31 @@ describe('ClaudeBinaryResolver', () => {
|
||||||
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
|
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 () => {
|
it('ignores the dedicated orchestrator overrides when Claude flavor is selected', async () => {
|
||||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
|
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
|
||||||
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-dev';
|
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-dev';
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ import {
|
||||||
buildTodoPath,
|
buildTodoPath,
|
||||||
decodePath,
|
decodePath,
|
||||||
encodePath,
|
encodePath,
|
||||||
|
encodePathPortable,
|
||||||
extractProjectName,
|
extractProjectName,
|
||||||
extractSessionId,
|
extractSessionId,
|
||||||
|
getProjectDirNameCandidates,
|
||||||
getAppDataPath,
|
getAppDataPath,
|
||||||
getProjectsBasePath,
|
getProjectsBasePath,
|
||||||
getSchedulesBasePath,
|
getSchedulesBasePath,
|
||||||
|
|
@ -72,6 +74,12 @@ describe('pathDecoder', () => {
|
||||||
it('should encode a Linux-style path', () => {
|
it('should encode a Linux-style path', () => {
|
||||||
expect(encodePath('/home/user/projects/myapp')).toBe('-home-user-projects-myapp');
|
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', () => {
|
describe('decodePath', () => {
|
||||||
|
|
@ -188,12 +196,27 @@ describe('pathDecoder', () => {
|
||||||
expect(isValidEncodedPath('C--Users-username-projectname')).toBe(true);
|
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', () => {
|
it('should return false for misplaced colons', () => {
|
||||||
expect(isValidEncodedPath('-Users-username:project')).toBe(false);
|
expect(isValidEncodedPath('-Users-username:project')).toBe(false);
|
||||||
expect(isValidEncodedPath('-C:-Users-name-project:extra')).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', () => {
|
describe('extractSessionId', () => {
|
||||||
it('should extract session ID from JSONL filename', () => {
|
it('should extract session ID from JSONL filename', () => {
|
||||||
expect(extractSessionId('abc123.jsonl')).toBe('abc123');
|
expect(extractSessionId('abc123.jsonl')).toBe('abc123');
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { getHomeDir, setClaudeBasePathOverride } from '../../../src/main/utils/pathDecoder';
|
import { getHomeDir, setClaudeBasePathOverride } from '../../../src/main/utils/pathDecoder';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
isPathWithinRoot,
|
||||||
isPathWithinAllowedDirectories,
|
isPathWithinAllowedDirectories,
|
||||||
|
isWindowsReservedFileName,
|
||||||
|
validateFileName,
|
||||||
validateFilePath,
|
validateFilePath,
|
||||||
validateOpenPath,
|
validateOpenPath,
|
||||||
validateOpenPathUserSelected,
|
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('validateFilePath', () => {
|
||||||
describe('basic validation', () => {
|
describe('basic validation', () => {
|
||||||
it('should reject empty path', () => {
|
it('should reject empty path', () => {
|
||||||
|
|
|
||||||
42
test/main/utils/windowsProcessTable.test.ts
Normal file
42
test/main/utils/windowsProcessTable.test.ts
Normal file
|
|
@ -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' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue