fix(opencode): harden Windows junction retry
This commit is contained in:
parent
b12106d8f4
commit
8abf4ea7dd
4 changed files with 251 additions and 46 deletions
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue