diff --git a/packages/core/src/config/storage.path.test.ts b/packages/core/src/config/storage.path.test.ts new file mode 100644 index 0000000000..f97bf2a868 --- /dev/null +++ b/packages/core/src/config/storage.path.test.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as os from 'node:os'; +import { Storage } from './storage.js'; +import * as paths from '../utils/paths.js'; + +vi.mock('../utils/paths.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: vi.fn(actual.homedir), + resolveToRealPath: vi.fn(actual.resolveToRealPath), + }; +}); + +describe('Storage.isWorkspaceHomeDir', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(paths.homedir).mockImplementation(os.homedir); + vi.mocked(paths.resolveToRealPath).mockImplementation((p) => p); + }); + + it('returns true when targetDir is the same as homedir', () => { + const home = '/Users/test'; + vi.mocked(paths.homedir).mockReturnValue(home); + const storage = new Storage(home); + + expect(storage.isWorkspaceHomeDir()).toBe(true); + }); + + it('returns true when paths differ only by casing on case-insensitive platforms', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + const home = '/Users/Test'; + const target = '/users/test'; + vi.mocked(paths.homedir).mockReturnValue(home); + const storage = new Storage(target); + + expect(storage.isWorkspaceHomeDir()).toBe(true); + }); + + it('returns true when paths differ by slashes (simulating win32)', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + const home = 'C:\\\\Users\\\\Test'; + const target = 'C:/Users/Test'; + vi.mocked(paths.homedir).mockReturnValue(home); + const storage = new Storage(target); + + expect(storage.isWorkspaceHomeDir()).toBe(true); + }); + + it('returns true when one path has the \\\\?\\ prefix on Windows', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + const home = '\\\\?\\C:\\\\Users\\\\Test'; + const target = 'C:\\\\Users\\\\Test'; + + vi.mocked(paths.homedir).mockReturnValue(home); + const storage = new Storage(target); + expect(storage.isWorkspaceHomeDir()).toBe(true); + }); + + it('returns false when targetDir is a subdirectory of homedir', () => { + const home = '/Users/test'; + const target = '/Users/test/projects/my-project'; + vi.mocked(paths.homedir).mockReturnValue(home); + const storage = new Storage(target); + + expect(storage.isWorkspaceHomeDir()).toBe(false); + }); + + it('returns true if paths are technically the same but formatted differently (repro attempt)', () => { + // Simulate Windows short names if we can + const home = 'C:\\Users\\HENRIQ~1'; + const target = 'C:\\Users\\Henrique'; + + vi.mocked(paths.homedir).mockReturnValue(home); + + // In reality, resolveToRealPath(home) and resolveToRealPath(target) + // should both return C:\Users\Henrique + vi.mocked(paths.resolveToRealPath).mockImplementation((p) => { + if (p === home || p === target) return 'C:\\Users\\Henrique'; + return p; + }); + + const storage = new Storage(target); + expect(storage.isWorkspaceHomeDir()).toBe(true); + }); +}); diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index fcc3cddc84..66e312d71a 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -14,7 +14,6 @@ import { GOOGLE_ACCOUNTS_FILENAME, isSubpath, resolveToRealPath, - normalizePath, } from '../utils/paths.js'; import { ProjectRegistry } from './projectRegistry.js'; import { StorageMigration } from './storageMigration.js'; @@ -168,10 +167,32 @@ export class Storage { * This handles symlinks and platform-specific path normalization. */ isWorkspaceHomeDir(): boolean { - return ( - normalizePath(resolveToRealPath(this.targetDir)) === - normalizePath(resolveToRealPath(homedir())) - ); + const target = resolveToRealPath(this.targetDir); + const home = resolveToRealPath(homedir()); + + const platform = process.platform; + const isWindows = platform === 'win32'; + const isDarwin = platform === 'darwin'; + const pathModule = isWindows ? path.win32 : path; + + // Strip Windows long path prefix (\\?\) if present, as it can cause + // discrepancies in string comparisons and path.relative. + const stripPrefix = (p: string) => + isWindows && p.startsWith('\\\\?\\') ? p.substring(4) : p; + + let t = stripPrefix(target); + let h = stripPrefix(home); + + // Use path.relative to robustly check for path equality. + // On Windows, path.win32.relative is case-insensitive. + // On macOS (Darwin), we manually lowercase to ensure case-insensitive comparison + // to match user expectation and sandbox policy. + if (isDarwin) { + t = t.toLowerCase(); + h = h.toLowerCase(); + } + + return pathModule.relative(t, h) === ''; } getAgentsDir(): string {