Merge pull request #188 from ComradeSwarog/fix/windows-opencode-eperm-symlink-fallback

fix(opencode): add Windows junction fallback for node_modules EPERM symlink error
This commit is contained in:
Илия 2026-05-28 13:48:11 +03:00 committed by GitHub
commit b4fbbed4f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 857 additions and 14 deletions

View file

@ -6,6 +6,12 @@ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess';
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
import {
ensureOpenCodeProfileNodeModulesJunction,
extractProfileIdFromSymlinkError,
isOpenCodeNodeModulesSymlinkError,
} from './openCodeWindowsNodeModulesJunction';
import type {
RuntimeProviderManagementApi,
RuntimeProviderManagementConnectApiKeyInput,
@ -236,7 +242,7 @@ function buildOpenCodeProfileNodeModulesLinkDiagnostics(
const summary = 'OpenCode managed profile node_modules link was blocked.';
const likelyCause =
'Windows denied creating the managed OpenCode profile node_modules link. Newer Agent Teams runtimes fall back to a junction or local profile directory.';
'Windows denied creating the managed OpenCode profile node_modules link. The runtime does not yet fall back to a junction or local profile directory on Windows — this is a known limitation.';
return {
summary,
likelyCause,
@ -247,9 +253,9 @@ function buildOpenCodeProfileNodeModulesLinkDiagnostics(
stderrPreview: message,
stdoutPreview: null,
hints: [
'Update the Agent Teams runtime and refresh the OpenCode provider catalog.',
'If you must use an older runtime, enable Windows Developer Mode or run Agent Teams AI as Administrator.',
'If the error persists after updating, refresh again so the runtime can rebuild the managed OpenCode profile node_modules path.',
'The next runtime update will include automatic junction fallback for Windows.',
'As a temporary workaround, enable Windows Developer Mode or run Agent Teams AI as Administrator.',
'After enabling Developer Mode, refresh the OpenCode provider catalog.',
],
};
}
@ -1085,13 +1091,38 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
stderr
);
} catch (error) {
const response = extractJsonObjectFromError<RuntimeProviderManagementViewResponse>(error);
if (response) {
return response;
const failure = normalizeCommandFailure(error, context);
if (process.platform === 'win32' && isOpenCodeNodeModulesSymlinkError(failure.message)) {
const profileId = extractProfileIdFromSymlinkError(failure.message);
if (profileId) {
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.
}
}
}
}
const retryResponse = extractJsonObjectFromError<RuntimeProviderManagementViewResponse>(error);
if (retryResponse) {
return retryResponse;
}
return commandFailureResponse<RuntimeProviderManagementViewResponse>(
input.runtimeId,
normalizeCommandFailure(error, context)
failure
);
}
}
@ -1140,14 +1171,39 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
stderr
);
} catch (error) {
const response =
const failure = normalizeCommandFailure(error, context);
if (process.platform === 'win32' && isOpenCodeNodeModulesSymlinkError(failure.message)) {
const profileId = extractProfileIdFromSymlinkError(failure.message);
if (profileId) {
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.
}
}
}
}
const retryResponse =
extractJsonObjectFromError<RuntimeProviderManagementDirectoryResponse>(error);
if (response) {
return response;
if (retryResponse) {
return retryResponse;
}
return commandFailureResponse<RuntimeProviderManagementDirectoryResponse>(
input.runtimeId,
normalizeCommandFailure(error, context)
failure
);
}
}

View file

@ -0,0 +1,189 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const OPENCODE_SHARED_CACHE_NODE_MODULES_RELATIVE = path.join(
'Cache',
'opencode',
'shared-cache',
'config-node_modules'
);
const OPENCODE_PROFILES_BASE_RELATIVE = path.join(
'Data',
'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');
}
function getBaseDir(): string {
return path.join(getLocalAppDataPath(), 'claude-multimodel-nodejs');
}
export function getSharedCacheNodeModulesPath(): string {
return path.join(getBaseDir(), OPENCODE_SHARED_CACHE_NODE_MODULES_RELATIVE);
}
export function getProfileNodeModulesPath(profileId: string): string {
return path.join(
getBaseDir(),
OPENCODE_PROFILES_BASE_RELATIVE,
profileId,
'config',
'opencode',
'node_modules'
);
}
export function isOpenCodeNodeModulesSymlinkError(message: string): boolean {
const normalized = message.toLowerCase();
return (
(normalized.includes('eperm') || normalized.includes('eacces')) &&
normalized.includes('symlink') &&
normalized.includes('opencode') &&
normalized.includes('node_modules')
);
}
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(normalizeErrorPathSeparators(message));
return match ? match[1] : null;
}
const SYMLINK_SOURCE_PATTERN = /symlink\s+'([^']+)'/i;
const SYMLINK_TARGET_PATTERN = /->\s+'([^']+)'/i;
export function extractSymlinkSourcePath(message: string): string | null {
const match = SYMLINK_SOURCE_PATTERN.exec(message);
return match ? normalizeErrorPathSeparators(match[1]) : null;
}
export function extractSymlinkTargetPath(message: string): string | null {
const match = SYMLINK_TARGET_PATTERN.exec(message);
return match ? normalizeErrorPathSeparators(match[1]) : null;
}
export function ensureOpenCodeProfileNodeModulesJunction(
profileId: string,
errorMessage?: string
): boolean {
if (process.platform !== 'win32') {
return false;
}
let source = getSharedCacheNodeModulesPath();
let target = getProfileNodeModulesPath(profileId);
if (errorMessage) {
const extractedSource = extractSymlinkSourcePath(errorMessage);
const extractedTarget = extractSymlinkTargetPath(errorMessage);
if (
extractedTarget &&
isExpectedProfileNodeModulesPath(extractedTarget, profileId) &&
(!extractedSource || isExpectedSharedCacheNodeModulesPath(extractedSource)) &&
(!extractedSource || extractedPathsShareBase(extractedSource, extractedTarget, profileId))
) {
target = extractedTarget;
source = extractedSource ?? source;
}
}
try {
const existingStat = fs.statSync(target, { throwIfNoEntry: false });
if (existingStat !== undefined) {
return true;
}
} catch {
// Target does not exist, proceed to create junction.
}
try {
const sourceStat = fs.statSync(source, { throwIfNoEntry: false });
if (sourceStat === undefined) {
return false;
}
} catch {
return false;
}
const parentDir = path.dirname(target);
try {
fs.mkdirSync(parentDir, { recursive: true });
} catch {
return false;
}
try {
fs.symlinkSync(source, target, 'junction');
return true;
} catch {
return false;
}
}

View file

@ -73,7 +73,21 @@ vi.mock('@main/utils/shellEnv', () => ({
resolveInteractiveShellEnvBestEffort: () => resolveInteractiveShellEnvMock(),
}));
vi.mock(
'../../../../src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction',
() => ({
isOpenCodeNodeModulesSymlinkError: vi.fn(),
extractProfileIdFromSymlinkError: vi.fn(),
ensureOpenCodeProfileNodeModulesJunction: vi.fn(),
})
);
import { AgentTeamsRuntimeProviderManagementCliClient } from '../../../../src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient';
import {
ensureOpenCodeProfileNodeModulesJunction as ensureOpenCodeProfileNodeModulesJunctionMock,
extractProfileIdFromSymlinkError as extractProfileIdFromSymlinkErrorMock,
isOpenCodeNodeModulesSymlinkError as isOpenCodeNodeModulesSymlinkErrorMock,
} from '../../../../src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction';
describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
beforeEach(() => {
@ -893,12 +907,237 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
expect(response.error?.diagnostics?.stderrPreview).toBe(runtimeMessage);
expect(response.error?.diagnostics?.hints).toEqual(
expect.arrayContaining([
'Update the Agent Teams runtime and refresh the OpenCode provider catalog.',
'If you must use an older runtime, enable Windows Developer Mode or run Agent Teams AI as Administrator.',
'The next runtime update will include automatic junction fallback for Windows.',
'As a temporary workaround, enable Windows Developer Mode or run Agent Teams AI as Administrator.',
])
);
});
it('attempts junction pre-seed and retry on Windows when EPERM symlink error is detected 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 firstError = new Error('Command failed: /repo/cli-dev runtime providers view');
Object.assign(firstError, {
stdout: JSON.stringify({
schemaVersion: 1,
runtimeId: 'opencode',
error: { code: 'runtime-unhealthy', message: runtimeMessage, recoverable: true },
}),
stderr: '',
});
const successResponse = {
schemaVersion: 1,
runtimeId: 'opencode',
view: {
runtimeId: 'opencode',
title: 'OpenCode',
runtime: { state: 'ready', cliPath: '/repo/cli-dev', version: '1.15.6', managedProfile: 'active', localAuth: 'synced' },
providers: [],
defaultModel: null,
fallbackModel: null,
diagnostics: [],
},
};
execCliMock
.mockRejectedValueOnce(firstError)
.mockResolvedValueOnce({ stdout: JSON.stringify(successResponse), stderr: '' });
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(true);
try {
const client = new AgentTeamsRuntimeProviderManagementCliClient();
const response = await client.loadView({ runtimeId: 'opencode' });
expect(ensureOpenCodeProfileNodeModulesJunctionMock).toHaveBeenCalledWith('abc123', expect.any(String));
expect(execCliMock).toHaveBeenCalledTimes(2);
expect(response.error).toBeUndefined();
expect(response.view?.runtime?.state).toBe('ready');
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform });
vi.mocked(isOpenCodeNodeModulesSymlinkErrorMock).mockRestore();
vi.mocked(extractProfileIdFromSymlinkErrorMock).mockRestore();
vi.mocked(ensureOpenCodeProfileNodeModulesJunctionMock).mockRestore();
}
});
it('falls back to error response when junction pre-seed succeeds but retry also 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(true);
try {
const client = new AgentTeamsRuntimeProviderManagementCliClient();
const response = await client.loadView({ runtimeId: 'opencode' });
expect(ensureOpenCodeProfileNodeModulesJunctionMock).toHaveBeenCalledWith('abc123', expect.any(String));
expect(execCliMock).toHaveBeenCalledTimes(2);
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 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:',
"EPERM: operation not permitted, symlink '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: 'darwin' });
(isOpenCodeNodeModulesSymlinkErrorMock as ReturnType<typeof vi.fn>).mockReturnValue(true);
(extractProfileIdFromSymlinkErrorMock as ReturnType<typeof vi.fn>).mockReturnValue('abc123');
try {
const client = new AgentTeamsRuntimeProviderManagementCliClient();
const response = await client.loadView({ runtimeId: 'opencode' });
expect(ensureOpenCodeProfileNodeModulesJunctionMock).not.toHaveBeenCalled();
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();
}
});
it('attempts junction pre-seed and retry on Windows for loadProviderDirectory', 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\\def456\\config\\opencode\\node_modules'",
].join(' ');
const firstError = new Error('Command failed: /repo/cli-dev runtime providers directory');
Object.assign(firstError, {
stdout: '',
stderr: runtimeMessage,
});
const successResponse = {
schemaVersion: 1,
runtimeId: 'opencode',
directory: {
runtimeId: 'opencode',
totalCount: 0,
returnedCount: 0,
query: null,
filter: 'all',
limit: 50,
cursor: null,
nextCursor: null,
entries: [],
diagnostics: [],
fetchedAt: new Date().toISOString(),
},
};
execCliMock
.mockRejectedValueOnce(firstError)
.mockResolvedValueOnce({ stdout: JSON.stringify(successResponse), stderr: '' });
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('def456');
(ensureOpenCodeProfileNodeModulesJunctionMock as ReturnType<typeof vi.fn>).mockReturnValue(true);
try {
const client = new AgentTeamsRuntimeProviderManagementCliClient();
const response = await client.loadProviderDirectory({ runtimeId: 'opencode' });
expect(ensureOpenCodeProfileNodeModulesJunctionMock).toHaveBeenCalledWith('def456', expect.any(String));
expect(execCliMock).toHaveBeenCalledTimes(2);
expect(response.directory?.entries).toEqual([]);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform });
vi.mocked(isOpenCodeNodeModulesSymlinkErrorMock).mockRestore();
vi.mocked(extractProfileIdFromSymlinkErrorMock).mockRestore();
vi.mocked(ensureOpenCodeProfileNodeModulesJunctionMock).mockRestore();
}
});
it('does not let non-object error logs shadow a later valid runtime response', async () => {
const validResponse = {
schemaVersion: 1,

View file

@ -0,0 +1,359 @@
import fs from 'node:fs';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
ensureOpenCodeProfileNodeModulesJunction,
extractProfileIdFromSymlinkError,
extractSymlinkSourcePath,
extractSymlinkTargetPath,
getProfileNodeModulesPath,
getSharedCacheNodeModulesPath,
isOpenCodeNodeModulesSymlinkError,
} from '../../../../src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction';
describe('openCodeWindowsNodeModulesJunction', () => {
describe('isOpenCodeNodeModulesSymlinkError', () => {
it('matches EPERM symlink errors containing opencode and node_modules', () => {
const message = [
'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(' ');
expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(true);
});
it('matches EACCES symlink errors containing opencode and node_modules', () => {
const message =
"EACCES: access denied, symlink 'opencode' -> 'node_modules'";
expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(true);
});
it('is case-insensitive', () => {
const message =
"eperm: operation not permitted, SYMLINK 'OpenCode' -> 'NODE_MODULES'";
expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(true);
});
it('does not match errors missing symlink keyword', () => {
const message =
"EPERM: operation not permitted, open 'opencode' -> 'node_modules'";
expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(false);
});
it('does not match errors missing opencode keyword', () => {
const message =
"EPERM: operation not permitted, symlink '/some/path' -> 'node_modules'";
expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(false);
});
it('does not match errors missing node_modules keyword', () => {
const message =
"EPERM: operation not permitted, symlink 'opencode' -> '/some/path'";
expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(false);
});
});
describe('extractProfileIdFromSymlinkError', () => {
it('extracts the profile hash from a Windows path', () => {
const message = [
"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'",
].join(' ');
expect(extractProfileIdFromSymlinkError(message)).toBe('e8e2eadb00beea6c');
});
it('extracts the profile hash from a Unix-style path', () => {
const message =
"EPERM: symlink '/home/user/.cache/opencode/shared-cache/config-node_modules' -> '/home/user/.data/opencode/profiles/abc123def456/config/opencode/node_modules'";
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();
});
});
describe('extractSymlinkSourcePath', () => {
it('extracts the source path from a Windows error message', () => {
const message =
"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'";
expect(extractSymlinkSourcePath(message)).toBe(
'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules'
);
});
it('extracts the source path from a single-quoted error', () => {
const message =
"EPERM: operation not permitted, symlink '/home/user/.cache/opencode/shared-cache/config-node_modules' -> '/home/user/.data/opencode/profiles/abc123/config/opencode/node_modules'";
expect(extractSymlinkSourcePath(message)).toBe(
'/home/user/.cache/opencode/shared-cache/config-node_modules'
);
});
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();
});
});
describe('extractSymlinkTargetPath', () => {
it('extracts the target path from a Windows error message', () => {
const message =
"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'";
expect(extractSymlinkTargetPath(message)).toBe(
'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\e8e2eadb00beea6c\\config\\opencode\\node_modules'
);
});
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();
});
});
describe('getSharedCacheNodeModulesPath', () => {
it('uses LOCALAPPDATA environment variable when set', () => {
const originalEnv = process.env.LOCALAPPDATA;
process.env.LOCALAPPDATA = 'X:\\custom\\local';
try {
const result = getSharedCacheNodeModulesPath();
expect(result).toBe(
path.join('X:\\custom\\local', 'claude-multimodel-nodejs', 'Cache', 'opencode', 'shared-cache', 'config-node_modules')
);
} finally {
process.env.LOCALAPPDATA = originalEnv;
}
});
it('falls back to homedir AppData Local when LOCALAPPDATA is unset', () => {
const originalEnv = process.env.LOCALAPPDATA;
delete process.env.LOCALAPPDATA;
try {
const result = getSharedCacheNodeModulesPath();
expect(result).toContain('AppData');
expect(result).toContain('Local');
expect(result).toContain('claude-multimodel-nodejs');
} finally {
process.env.LOCALAPPDATA = originalEnv;
}
});
});
describe('getProfileNodeModulesPath', () => {
it('constructs the correct profile node_modules path', () => {
const originalEnv = process.env.LOCALAPPDATA;
process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local';
try {
const result = getProfileNodeModulesPath('abc123');
expect(result).toBe(
path.join(
'C:\\Users\\test\\AppData\\Local',
'claude-multimodel-nodejs',
'Data',
'opencode',
'profiles',
'abc123',
'config',
'opencode',
'node_modules'
)
);
} finally {
process.env.LOCALAPPDATA = originalEnv;
}
});
});
describe('ensureOpenCodeProfileNodeModulesJunction', () => {
const originalPlatform = process.platform;
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('returns false on non-Windows platforms', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
const result = ensureOpenCodeProfileNodeModulesJunction('abc123');
expect(result).toBe(false);
});
it('returns false on Windows when shared cache does not exist', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation(
(..._args: Parameters<typeof fs.statSync>) => {
throw new Error('ENOENT');
}
);
const result = ensureOpenCodeProfileNodeModulesJunction('abc123');
expect(result).toBe(false);
statSyncSpy.mockRestore();
});
it('returns true on Windows when target node_modules already exists', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation(
(..._args: Parameters<typeof fs.statSync>) => {
return {} as fs.Stats;
}
);
const result = ensureOpenCodeProfileNodeModulesJunction('abc123');
expect(result).toBe(true);
statSyncSpy.mockRestore();
});
it('creates junction on Windows when shared cache exists and target is missing', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
let callCount = 0;
const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation(
(..._args: Parameters<typeof fs.statSync>) => {
callCount++;
if (callCount === 1) {
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);
const result = ensureOpenCodeProfileNodeModulesJunction('abc123');
expect(result).toBe(true);
expect(symlinkSyncSpy).toHaveBeenCalledTimes(1);
expect(symlinkSyncSpy.mock.calls[0][2]).toBe('junction');
statSyncSpy.mockRestore();
mkdirSyncSpy.mockRestore();
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;
const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation(
(..._args: Parameters<typeof fs.statSync>) => {
callCount2++;
if (callCount2 === 1) {
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(() => {
throw new Error('EPERM');
});
const result = ensureOpenCodeProfileNodeModulesJunction('abc123');
expect(result).toBe(false);
statSyncSpy.mockRestore();
mkdirSyncSpy.mockRestore();
symlinkSyncSpy.mockRestore();
});
});
});