fix(opencode): detect managed node_modules symlink permission failures

Распознаём отдельную диагностику для EPERM на создании managed
node_modules symlink под Windows и подсказываем пользователю
запустить приложение от имени Administrator. UI-подсказка и
provisioning hint показываются только для этого случая, обычный
Windows access-denied flow не затрагивается.
This commit is contained in:
777genius 2026-05-28 23:55:28 +03:00
parent 961770b7a7
commit 90fe3c9107
8 changed files with 215 additions and 2 deletions

View file

@ -26,6 +26,7 @@ import {
getOpenCodeTeamModelRecommendation,
isOpenCodeTeamModelRecommended,
} from '@renderer/utils/openCodeModelRecommendations';
import { isOpenCodeWindowsNodeModulesSymlinkPermissionDiagnostic } from '@shared/utils/openCodeWindowsAccessDenied';
import {
AlertTriangle,
Check,
@ -785,6 +786,20 @@ function getRuntimeProviderDiagnosticRows(
.map(([label, value]) => [label, String(value)]);
}
function isOpenCodeWindowsNodeModulesSymlinkPermissionError(
message: string,
diagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null | undefined
): boolean {
const value = [
message,
diagnostics?.stderrPreview ?? '',
diagnostics?.stdoutPreview ?? '',
diagnostics?.likelyCause ?? '',
...(diagnostics?.hints ?? []),
].join('\n');
return isOpenCodeWindowsNodeModulesSymlinkPermissionDiagnostic(value);
}
async function writeRuntimeProviderDiagnosticsToClipboard(text: string): Promise<boolean> {
if (navigator.clipboard?.writeText) {
try {
@ -826,6 +841,10 @@ const RuntimeProviderErrorAlert = ({
const [headline = message, ...detailLines] = message.trim().split(/\r?\n/);
const fallbackDetails = detailLines.join('\n').trim();
const hints = diagnostics?.hints ?? [];
const showWindowsSymlinkPermissionHint = isOpenCodeWindowsNodeModulesSymlinkPermissionError(
message,
diagnostics
);
const copyText = useMemo(
() => formatRuntimeProviderDiagnosticsCopyText(message, diagnostics),
[diagnostics, message]
@ -859,6 +878,11 @@ const RuntimeProviderErrorAlert = ({
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
<div className="min-w-0 whitespace-pre-wrap break-words font-medium leading-5">
{headline || message}
{showWindowsSymlinkPermissionHint ? (
<span className="ml-2 inline-flex rounded border border-red-200/30 bg-red-500/10 px-1.5 py-0.5 text-[11px] font-semibold leading-4 text-red-50">
{t('runtimeProvider.diagnostics.windowsSymlinkAdminHint')}
</span>
) : null}
</div>
<Button
type="button"

View file

@ -1,9 +1,24 @@
import { OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE } from '@shared/utils/openCodeWindowsAccessDenied';
import {
OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE,
OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE,
} from '@shared/utils/openCodeWindowsAccessDenied';
import { describe, expect, it } from 'vitest';
import { getProvisioningFailureHint } from './ProvisioningProviderStatusList';
describe('getProvisioningFailureHint', () => {
it('returns the administrator hint for the exact OpenCode node_modules symlink permission failure', () => {
expect(
getProvisioningFailureHint(null, [
{
providerId: 'opencode',
status: 'failed',
details: [OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE],
},
])
).toBe('Run Agent Teams AI as Administrator, then retry launch.');
});
it('returns the OpenCode Windows permissions hint for OpenCode access-denied details', () => {
expect(
getProvisioningFailureHint(null, [

View file

@ -5,7 +5,9 @@ import { formatProviderBackendLabel } from '@renderer/utils/providerBackendIdent
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import {
isOpenCodeWindowsAccessDeniedDiagnostic,
isOpenCodeWindowsNodeModulesSymlinkPermissionDiagnostic,
OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE,
OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE,
} from '@shared/utils/openCodeWindowsAccessDenied';
import { AlertTriangle, Check, CheckCircle2, Copy, Loader2, SlidersHorizontal } from 'lucide-react';
@ -1042,14 +1044,31 @@ export function getProvisioningFailureHint(
const hasOpenCodeAccessDeniedDetail = failedOpenCodeChecks.some((check) =>
check.details.some(isOpenCodeWindowsAccessDeniedDiagnostic)
);
const hasOpenCodeNodeModulesSymlinkPermissionDetail = failedOpenCodeChecks.some((check) =>
check.details.some(isOpenCodeWindowsNodeModulesSymlinkPermissionDiagnostic)
);
const hasOpenCodeBridgeNoOutputDetail = failedOpenCodeChecks.some((check) =>
check.details.some(isOpenCodeBridgeNoOutputDiagnostic)
);
const normalizedMessage = message?.trim() ?? '';
const hasOpenCodeNodeModulesSymlinkPermissionMessage =
failedOpenCodeChecks.length > 0 &&
(normalizedMessage === OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE ||
(!hasFailedNonOpenCodeCheck &&
isOpenCodeWindowsNodeModulesSymlinkPermissionDiagnostic(normalizedMessage)));
const hasOpenCodeAccessDeniedMessage =
failedOpenCodeChecks.length > 0 &&
(normalizedMessage === OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE ||
(!hasFailedNonOpenCodeCheck && isOpenCodeWindowsAccessDeniedDiagnostic(normalizedMessage)));
if (
hasOpenCodeNodeModulesSymlinkPermissionDetail ||
hasOpenCodeNodeModulesSymlinkPermissionMessage
) {
return (
t?.('provisioning.providerStatus.failureHints.openCodeNodeModulesSymlinkPermission') ??
'Run Agent Teams AI as Administrator, then retry launch.'
);
}
if (hasOpenCodeAccessDeniedDetail || hasOpenCodeAccessDeniedMessage) {
return (
t?.('provisioning.providerStatus.failureHints.openCodeAccessDenied') ??

View file

@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest';
import {
isOpenCodeWindowsAccessDeniedDiagnostic,
isOpenCodeWindowsNodeModulesSymlinkPermissionDiagnostic,
normalizeOpenCodeWindowsAccessDeniedDiagnostic,
OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE,
OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE,
} from '../openCodeWindowsAccessDenied';
describe('OpenCode Windows access-denied diagnostics', () => {
@ -24,4 +26,18 @@ describe('OpenCode Windows access-denied diagnostics', () => {
expect(isOpenCodeWindowsAccessDeniedDiagnostic('OpenCode app MCP is unreachable')).toBe(false);
expect(normalizeOpenCodeWindowsAccessDeniedDiagnostic('OpenCode CLI not found')).toBeNull();
});
it('detects the managed OpenCode node_modules symlink permission failure separately', () => {
const message = [
'Runtime provider management command failed unexpectedly:',
"EPERM: operation not permitted, symlink 'C:\\Users\\ben\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules'",
"-> 'C:\\Users\\ben\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\abc123\\config\\opencode\\node_modules'",
].join(' ');
expect(isOpenCodeWindowsNodeModulesSymlinkPermissionDiagnostic(message)).toBe(true);
expect(isOpenCodeWindowsAccessDeniedDiagnostic(message)).toBe(true);
expect(normalizeOpenCodeWindowsAccessDeniedDiagnostic(message)).toBe(
OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE
);
});
});

View file

@ -1,15 +1,35 @@
export const OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE =
'Windows blocked OpenCode from accessing project or runtime files. Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.';
export const OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE =
'Windows blocked OpenCode from creating the managed node_modules symlink. Run Agent Teams AI as Administrator, then retry launch.';
const OPENCODE_WINDOWS_ACCESS_DENIED_PATTERN =
/\b(?:EPERM|EACCES)\b|access is denied|permission denied|operation not permitted/i;
const OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_PATTERN =
/(?=[\s\S]*\bEPERM\b)(?=[\s\S]*operation not permitted)(?=[\s\S]*\bsymlink\b)(?=[\s\S]*opencode)(?=[\s\S]*node_modules)(?=[\s\S]*(?:[A-Z]:\\|AppData\\Local\\claude-multimodel-nodejs))/i;
export function isOpenCodeWindowsNodeModulesSymlinkPermissionDiagnostic(
value: string | null | undefined
): boolean {
const trimmed = value?.trim();
if (!trimmed) {
return false;
}
return (
trimmed === OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE ||
OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_PATTERN.test(trimmed)
);
}
export function isOpenCodeWindowsAccessDeniedDiagnostic(value: string | null | undefined): boolean {
const trimmed = value?.trim();
if (!trimmed) {
return false;
}
return (
isOpenCodeWindowsNodeModulesSymlinkPermissionDiagnostic(trimmed) ||
trimmed === OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE ||
OPENCODE_WINDOWS_ACCESS_DENIED_PATTERN.test(trimmed)
);
@ -18,6 +38,9 @@ export function isOpenCodeWindowsAccessDeniedDiagnostic(value: string | null | u
export function normalizeOpenCodeWindowsAccessDeniedDiagnostic(
value: string | null | undefined
): string | null {
if (isOpenCodeWindowsNodeModulesSymlinkPermissionDiagnostic(value)) {
return OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE;
}
return isOpenCodeWindowsAccessDeniedDiagnostic(value)
? OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE
: null;

View file

@ -1,5 +1,8 @@
import { buildCodexWorkspaceTrustSettingsArgs } from '@features/workspace-trust/core/domain';
import { OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE } from '@shared/utils/openCodeWindowsAccessDenied';
import {
OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE,
OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE,
} from '@shared/utils/openCodeWindowsAccessDenied';
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
import { spawn } from 'child_process';
import * as fs from 'fs';
@ -1026,6 +1029,42 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
expect(result.warnings).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
});
it('keeps OpenCode managed node_modules symlink EPERM diagnostics specific during prepareForProvisioning', async () => {
const prepare = vi.fn(async () => ({
ok: false as const,
providerId: 'opencode' as const,
reason: 'unknown_error',
retryable: false,
diagnostics: [],
warnings: [
[
'Runtime provider management command failed unexpectedly:',
"EPERM: operation not permitted, symlink 'C:\\Users\\ben\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules'",
"-> 'C:\\Users\\ben\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\abc123\\config\\opencode\\node_modules'",
].join(' '),
],
}));
const adapter: TeamLaunchRuntimeAdapter = {
providerId: 'opencode',
prepare,
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
};
const registry = new TeamRuntimeAdapterRegistry([adapter]);
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(registry);
const result = await svc.prepareForProvisioning(tempRoot, {
providerId: 'opencode',
forceFresh: true,
});
expect(result.ready).toBe(false);
expect(result.message).toBe(OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE);
expect(result.warnings).toEqual([OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE]);
});
it('keeps OpenCode access-denied selected-model failures provider-scoped', async () => {
const prepare = vi.fn(async () => ({
ok: false as const,

View file

@ -3,6 +3,7 @@ import {
mergeReusableProviderPrepareModelResults,
runProviderPrepareDiagnostics,
} from '@renderer/components/team/dialogs/providerPrepareDiagnostics';
import { OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE } from '@shared/utils/openCodeWindowsAccessDenied';
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
import { describe, expect, it, vi } from 'vitest';
@ -488,6 +489,43 @@ describe('runProviderPrepareDiagnostics', () => {
expect(result.modelResultsById).toEqual({});
});
it('keeps the OpenCode node_modules symlink EPERM failure on the administrator hint path', async () => {
const symlinkError = [
'Runtime provider management command failed unexpectedly:',
"EPERM: operation not permitted, symlink 'C:\\Users\\ben\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules'",
"-> 'C:\\Users\\ben\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\abc123\\config\\opencode\\node_modules'",
].join(' ');
const prepareProvisioning = vi.fn<
(
cwd?: string,
providerId?: TeamProviderId,
providerIds?: TeamProviderId[],
selectedModels?: string[],
limitContext?: boolean,
modelVerificationMode?: 'compatibility' | 'deep'
) => Promise<TeamProvisioningPrepareResult>
>(() =>
Promise.resolve({
ready: false,
message: symlinkError,
details: [symlinkError],
warnings: [symlinkError],
})
);
const result = await runProviderPrepareDiagnostics({
cwd: '/tmp/project',
providerId: 'opencode',
selectedModelIds: ['opencode/big-pickle'],
prepareProvisioning,
});
expect(result.status).toBe('failed');
expect(result.details).toEqual([OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE]);
expect(result.warnings).toEqual([OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE]);
expect(result.modelResultsById).toEqual({});
});
it('treats OpenCode compatibility verification warnings as blocking when the batch failed', async () => {
const prepareProvisioning = vi.fn<
(

View file

@ -232,6 +232,45 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(details?.className).toContain('font-mono');
});
it('shows the Windows administrator hint only for OpenCode node_modules symlink EPERM errors', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const symlinkError = [
'Runtime provider management command failed unexpectedly:',
"EPERM: operation not permitted, symlink 'C:\\Users\\ben\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules'",
"-> 'C:\\Users\\ben\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\abc123\\config\\opencode\\node_modules'",
].join(' ');
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({ error: symlinkError }),
actions: createActions(),
disabled: false,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Windows: run Agent Teams AI as Administrator');
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
error: 'EPERM: operation not permitted, mkdir C:\\Program Files\\locked-project',
}),
actions: createActions(),
disabled: false,
})
);
await Promise.resolve();
});
expect(host.textContent).not.toContain('Windows: run Agent Teams AI as Administrator');
});
it('copies fallback error text when structured diagnostics are unavailable', async () => {
const host = document.createElement('div');
document.body.appendChild(host);