From 8abf4ea7ddcc660bfa4dc2a930be775541d646c1 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 28 May 2026 12:26:34 +0300 Subject: [PATCH] fix(opencode): harden Windows junction retry --- ...TeamsRuntimeProviderManagementCliClient.ts | 60 +++++----- .../openCodeWindowsNodeModulesJunction.ts | 89 ++++++++++++--- ...RuntimeProviderManagementCliClient.test.ts | 44 +++++++- ...openCodeWindowsNodeModulesJunction.test.ts | 104 +++++++++++++++++- 4 files changed, 251 insertions(+), 46 deletions(-) diff --git a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts index 274947cb..b37a61ee 100644 --- a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts +++ b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts @@ -1092,20 +1092,22 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv if (process.platform === 'win32' && isOpenCodeNodeModulesSymlinkError(failure.message)) { const profileId = extractProfileIdFromSymlinkError(failure.message); if (profileId) { - ensureOpenCodeProfileNodeModulesJunction(profileId, failure.message); - try { - const retryResult = await execCli( - binaryPath, - args, - runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath) - ); - return extractJsonObjectWithContext( - retryResult.stdout, - context, - retryResult.stderr - ); - } catch { - // Retry also failed; fall through to return the original error. + const junctionReady = ensureOpenCodeProfileNodeModulesJunction(profileId, failure.message); + if (junctionReady) { + try { + const retryResult = await execCli( + binaryPath, + args, + runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath) + ); + return extractJsonObjectWithContext( + retryResult.stdout, + context, + retryResult.stderr + ); + } catch { + // Retry also failed; fall through to return the original error. + } } } } @@ -1170,20 +1172,22 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv if (process.platform === 'win32' && isOpenCodeNodeModulesSymlinkError(failure.message)) { const profileId = extractProfileIdFromSymlinkError(failure.message); if (profileId) { - ensureOpenCodeProfileNodeModulesJunction(profileId, failure.message); - try { - const retryResult = await execCli( - binaryPath, - args, - runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath) - ); - return extractJsonObjectWithContext( - retryResult.stdout, - context, - retryResult.stderr - ); - } catch { - // Retry also failed; fall through to return the original error. + const junctionReady = ensureOpenCodeProfileNodeModulesJunction(profileId, failure.message); + if (junctionReady) { + try { + const retryResult = await execCli( + binaryPath, + args, + runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath) + ); + return extractJsonObjectWithContext( + retryResult.stdout, + context, + retryResult.stderr + ); + } catch { + // Retry also failed; fall through to return the original error. + } } } } diff --git a/src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction.ts b/src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction.ts index a41ffbc4..4c4ee386 100644 --- a/src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction.ts +++ b/src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction.ts @@ -13,6 +13,17 @@ const OPENCODE_PROFILES_BASE_RELATIVE = path.join( 'opencode', 'profiles' ); +const OPENCODE_SHARED_CACHE_SUFFIX_PARTS = [ + 'Cache', + 'opencode', + 'shared-cache', + 'config-node_modules', +]; +const OPENCODE_PROFILE_NODE_MODULES_SUFFIX_TAIL = [ + 'config', + 'opencode', + 'node_modules', +]; function getLocalAppDataPath(): string { return process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local'); @@ -47,10 +58,61 @@ export function isOpenCodeNodeModulesSymlinkError(message: string): boolean { ); } +function normalizeErrorPathSeparators(value: string): string { + return value.replace(/\\\\/g, '\\'); +} + +function normalizePathForComparison(value: string): string { + return normalizeErrorPathSeparators(value).replace(/[\\/]+/g, '/').toLowerCase(); +} + +function isAbsolutePath(candidate: string): boolean { + const normalized = normalizeErrorPathSeparators(candidate); + return path.win32.isAbsolute(normalized) || path.posix.isAbsolute(normalized); +} + +function getExpectedProfileSuffixParts(profileId: string): string[] { + return ['Data', 'opencode', 'profiles', profileId, ...OPENCODE_PROFILE_NODE_MODULES_SUFFIX_TAIL]; +} + +function getPathBaseBeforeSuffix(candidate: string, suffixParts: readonly string[]): string | null { + const normalized = normalizePathForComparison(candidate); + const suffix = suffixParts.join('/').toLowerCase(); + if (!normalized.endsWith(`/${suffix}`)) { + return null; + } + return normalized.slice(0, -suffix.length - 1); +} + +function isExpectedProfileNodeModulesPath(candidate: string, profileId: string): boolean { + return Boolean( + profileId && + isAbsolutePath(candidate) && + getPathBaseBeforeSuffix(candidate, getExpectedProfileSuffixParts(profileId)) + ); +} + +function isExpectedSharedCacheNodeModulesPath(candidate: string): boolean { + return Boolean( + isAbsolutePath(candidate) && + getPathBaseBeforeSuffix(candidate, OPENCODE_SHARED_CACHE_SUFFIX_PARTS) + ); +} + +function extractedPathsShareBase( + source: string, + target: string, + profileId: string +): boolean { + const sourceBase = getPathBaseBeforeSuffix(source, OPENCODE_SHARED_CACHE_SUFFIX_PARTS); + const targetBase = getPathBaseBeforeSuffix(target, getExpectedProfileSuffixParts(profileId)); + return Boolean(sourceBase && targetBase && sourceBase === targetBase); +} + export function extractProfileIdFromSymlinkError(message: string): string | null { const profilePathPattern = /profiles[\\/]([0-9a-f]+)[\\/]config[\\/]opencode[\\/]node_modules/i; - const match = profilePathPattern.exec(message); + const match = profilePathPattern.exec(normalizeErrorPathSeparators(message)); return match ? match[1] : null; } @@ -59,12 +121,12 @@ const SYMLINK_TARGET_PATTERN = /->\s+'([^']+)'/i; export function extractSymlinkSourcePath(message: string): string | null { const match = SYMLINK_SOURCE_PATTERN.exec(message); - return match ? match[1] : null; + return match ? normalizeErrorPathSeparators(match[1]) : null; } export function extractSymlinkTargetPath(message: string): string | null { const match = SYMLINK_TARGET_PATTERN.exec(message); - return match ? match[1] : null; + return match ? normalizeErrorPathSeparators(match[1]) : null; } export function ensureOpenCodeProfileNodeModulesJunction( @@ -75,23 +137,22 @@ export function ensureOpenCodeProfileNodeModulesJunction( return false; } - let source: string; - let target: string; + let source = getSharedCacheNodeModulesPath(); + let target = getProfileNodeModulesPath(profileId); if (errorMessage) { const extractedSource = extractSymlinkSourcePath(errorMessage); const extractedTarget = extractSymlinkTargetPath(errorMessage); - if (extractedTarget) { + if ( + extractedTarget && + isExpectedProfileNodeModulesPath(extractedTarget, profileId) && + (!extractedSource || isExpectedSharedCacheNodeModulesPath(extractedSource)) && + (!extractedSource || extractedPathsShareBase(extractedSource, extractedTarget, profileId)) + ) { target = extractedTarget; - source = extractedSource ?? getSharedCacheNodeModulesPath(); - } else { - target = getProfileNodeModulesPath(profileId); - source = getSharedCacheNodeModulesPath(); + source = extractedSource ?? source; } - } else { - target = getProfileNodeModulesPath(profileId); - source = getSharedCacheNodeModulesPath(); } try { @@ -125,4 +186,4 @@ export function ensureOpenCodeProfileNodeModulesJunction( } catch { return false; } -} \ No newline at end of file +} diff --git a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts index 3386b58e..5da5ca8b 100644 --- a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts +++ b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts @@ -83,11 +83,10 @@ vi.mock( ); import { AgentTeamsRuntimeProviderManagementCliClient } from '../../../../src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient'; - import { - isOpenCodeNodeModulesSymlinkError as isOpenCodeNodeModulesSymlinkErrorMock, - extractProfileIdFromSymlinkError as extractProfileIdFromSymlinkErrorMock, ensureOpenCodeProfileNodeModulesJunction as ensureOpenCodeProfileNodeModulesJunctionMock, + extractProfileIdFromSymlinkError as extractProfileIdFromSymlinkErrorMock, + isOpenCodeNodeModulesSymlinkError as isOpenCodeNodeModulesSymlinkErrorMock, } from '../../../../src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction'; describe('AgentTeamsRuntimeProviderManagementCliClient', () => { @@ -1009,6 +1008,45 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => { } }); + it('does not retry when junction pre-seed fails in loadView', async () => { + const runtimeMessage = [ + 'Runtime provider management command failed unexpectedly:', + "EPERM: operation not permitted, symlink 'C:\\Users\\test\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules'", + "-> 'C:\\Users\\test\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\abc123\\config\\opencode\\node_modules'", + ].join(' '); + const error = new Error('Command failed: /repo/cli-dev runtime providers view'); + Object.assign(error, { + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { code: 'runtime-unhealthy', message: runtimeMessage, recoverable: true }, + }), + stderr: '', + }); + + execCliMock.mockRejectedValue(error); + + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + (isOpenCodeNodeModulesSymlinkErrorMock as ReturnType).mockReturnValue(true); + (extractProfileIdFromSymlinkErrorMock as ReturnType).mockReturnValue('abc123'); + (ensureOpenCodeProfileNodeModulesJunctionMock as ReturnType).mockReturnValue(false); + + try { + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ runtimeId: 'opencode' }); + + expect(ensureOpenCodeProfileNodeModulesJunctionMock).toHaveBeenCalledWith('abc123', expect.any(String)); + expect(execCliMock).toHaveBeenCalledTimes(1); + expect(response.error?.message).toBe(runtimeMessage); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + vi.mocked(isOpenCodeNodeModulesSymlinkErrorMock).mockRestore(); + vi.mocked(extractProfileIdFromSymlinkErrorMock).mockRestore(); + vi.mocked(ensureOpenCodeProfileNodeModulesJunctionMock).mockRestore(); + } + }); + it('does not attempt junction retry on non-Windows platforms in loadView', async () => { const runtimeMessage = [ 'Runtime provider management command failed unexpectedly:', diff --git a/test/main/features/runtime-provider-management/openCodeWindowsNodeModulesJunction.test.ts b/test/main/features/runtime-provider-management/openCodeWindowsNodeModulesJunction.test.ts index 155856be..db875c8c 100644 --- a/test/main/features/runtime-provider-management/openCodeWindowsNodeModulesJunction.test.ts +++ b/test/main/features/runtime-provider-management/openCodeWindowsNodeModulesJunction.test.ts @@ -70,6 +70,17 @@ describe('openCodeWindowsNodeModulesJunction', () => { expect(extractProfileIdFromSymlinkError(message)).toBe('abc123def456'); }); + it('extracts the profile hash from JSON-escaped Windows paths', () => { + const runtimeMessage = + "EPERM: symlink 'C:\\Users\\test\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules' -> 'C:\\Users\\test\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\abc123\\config\\opencode\\node_modules'"; + const message = JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { message: runtimeMessage }, + }); + expect(extractProfileIdFromSymlinkError(message)).toBe('abc123'); + }); + it('returns null when no profile path pattern is found', () => { const message = 'EPERM: some other error without a profile path'; expect(extractProfileIdFromSymlinkError(message)).toBeNull(); @@ -93,6 +104,15 @@ describe('openCodeWindowsNodeModulesJunction', () => { ); }); + it('normalizes JSON-escaped Windows separators in the source path', () => { + const runtimeMessage = + "EPERM: operation not permitted, symlink 'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules' -> 'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\e8e2eadb00beea6c\\config\\opencode\\node_modules'"; + const message = JSON.stringify({ error: { message: runtimeMessage } }); + expect(extractSymlinkSourcePath(message)).toBe( + 'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules' + ); + }); + it('returns null when no source path is found', () => { const message = 'EPERM: some error without paths'; expect(extractSymlinkSourcePath(message)).toBeNull(); @@ -108,6 +128,15 @@ describe('openCodeWindowsNodeModulesJunction', () => { ); }); + it('normalizes JSON-escaped Windows separators in the target path', () => { + const runtimeMessage = + "EPERM: operation not permitted, symlink 'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules' -> 'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\e8e2eadb00beea6c\\config\\opencode\\node_modules'"; + const message = JSON.stringify({ error: { message: runtimeMessage } }); + expect(extractSymlinkTargetPath(message)).toBe( + 'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\e8e2eadb00beea6c\\config\\opencode\\node_modules' + ); + }); + it('returns null when no target path is found', () => { const message = "EPERM: operation not permitted, symlink '/some/path'"; expect(extractSymlinkTargetPath(message)).toBeNull(); @@ -229,6 +258,79 @@ describe('openCodeWindowsNodeModulesJunction', () => { symlinkSyncSpy.mockRestore(); }); + it('uses validated error-derived junction paths instead of the local process env', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + const originalEnv = process.env.LOCALAPPDATA; + process.env.LOCALAPPDATA = 'C:\\fallback\\local'; + const source = + 'D:\\runtime-root\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules'; + const target = + 'D:\\runtime-root\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\abc123\\config\\opencode\\node_modules'; + const message = JSON.stringify({ + error: { + message: `EPERM: operation not permitted, symlink '${source}' -> '${target}'`, + }, + }); + const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation( + (...args: Parameters) => { + if (String(args[0]) === target) { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + return {} as fs.Stats; + } + ); + const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => ''); + const symlinkSyncSpy = vi.spyOn(fs, 'symlinkSync').mockImplementation(() => undefined); + try { + const result = ensureOpenCodeProfileNodeModulesJunction('abc123', message); + expect(result).toBe(true); + expect(symlinkSyncSpy).toHaveBeenCalledWith(source, target, 'junction'); + } finally { + process.env.LOCALAPPDATA = originalEnv; + statSyncSpy.mockRestore(); + mkdirSyncSpy.mockRestore(); + symlinkSyncSpy.mockRestore(); + } + }); + + it('falls back to computed paths when error-derived paths fail validation', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + const originalEnv = process.env.LOCALAPPDATA; + process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local'; + const computedSource = getSharedCacheNodeModulesPath(); + const computedTarget = getProfileNodeModulesPath('abc123'); + const message = + "EPERM: operation not permitted, symlink 'C:\\Users\\test\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules' -> 'C:\\Temp\\outside\\node_modules'"; + const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation( + (...args: Parameters) => { + if (String(args[0]) === computedTarget) { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + return {} as fs.Stats; + } + ); + const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => ''); + const symlinkSyncSpy = vi.spyOn(fs, 'symlinkSync').mockImplementation(() => undefined); + try { + const result = ensureOpenCodeProfileNodeModulesJunction('abc123', message); + expect(result).toBe(true); + expect(symlinkSyncSpy).toHaveBeenCalledWith( + computedSource, + computedTarget, + 'junction' + ); + } finally { + process.env.LOCALAPPDATA = originalEnv; + statSyncSpy.mockRestore(); + mkdirSyncSpy.mockRestore(); + symlinkSyncSpy.mockRestore(); + } + }); + it('returns false when junction creation fails', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); let callCount2 = 0; @@ -254,4 +356,4 @@ describe('openCodeWindowsNodeModulesJunction', () => { symlinkSyncSpy.mockRestore(); }); }); -}); \ No newline at end of file +});