fix(opencode): harden Windows junction retry

This commit is contained in:
777genius 2026-05-28 12:26:34 +03:00
parent b12106d8f4
commit 8abf4ea7dd
4 changed files with 251 additions and 46 deletions

View file

@ -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<RuntimeProviderManagementViewResponse>(
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<RuntimeProviderManagementViewResponse>(
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<RuntimeProviderManagementDirectoryResponse>(
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<RuntimeProviderManagementDirectoryResponse>(
retryResult.stdout,
context,
retryResult.stderr
);
} catch {
// Retry also failed; fall through to return the original error.
}
}
}
}

View file

@ -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;
}
}
}

View file

@ -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<typeof vi.fn>).mockReturnValue(true);
(extractProfileIdFromSymlinkErrorMock as ReturnType<typeof vi.fn>).mockReturnValue('abc123');
(ensureOpenCodeProfileNodeModulesJunctionMock as ReturnType<typeof vi.fn>).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:',

View file

@ -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<typeof fs.statSync>) => {
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<typeof fs.statSync>) => {
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();
});
});
});
});