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:
parent
961770b7a7
commit
90fe3c9107
8 changed files with 215 additions and 2 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
|
|
@ -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') ??
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue