fix(windows): harden elevated runtime detection
This commit is contained in:
parent
e9a37e7325
commit
077c749cb7
9 changed files with 735 additions and 30 deletions
|
|
@ -28,8 +28,8 @@ import type {
|
|||
} from '@features/runtime-provider-management/contracts';
|
||||
import type { ChildProcessWithoutNullStreams } from 'child_process';
|
||||
|
||||
const COMMAND_TIMEOUT_MS = 45_000;
|
||||
const PROBE_COMMAND_TIMEOUT_MS = 90_000;
|
||||
const COMMAND_TIMEOUT_MS = PROBE_COMMAND_TIMEOUT_MS;
|
||||
const COMMAND_ERROR_DETAIL_LIMIT = 1_600;
|
||||
const COMMAND_OUTPUT_PREVIEW_LIMIT = 1_200;
|
||||
const ESCAPE_CHARACTER = String.fromCharCode(27);
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ import type {
|
|||
|
||||
const logger = createLogger('ClaudeMultimodelBridgeService');
|
||||
|
||||
const PROVIDER_STATUS_TIMEOUT_MS = 25_000;
|
||||
const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 15_000;
|
||||
const PROVIDER_STATUS_TIMEOUT_MS = 90_000;
|
||||
const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 30_000;
|
||||
const PROVIDER_MODELS_TIMEOUT_MS = 25_000;
|
||||
const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
||||
const PROVIDER_MODELS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
||||
|
|
@ -995,8 +995,11 @@ export class ClaudeMultimodelBridgeService {
|
|||
if (options.summary) {
|
||||
args.push('--summary');
|
||||
}
|
||||
const timeout =
|
||||
options.timeoutMs ??
|
||||
(options.summary ? PROVIDER_STATUS_SUMMARY_TIMEOUT_MS : PROVIDER_STATUS_TIMEOUT_MS);
|
||||
const { stdout } = await execCli(binaryPath, args, {
|
||||
timeout: options.timeoutMs ?? PROVIDER_STATUS_TIMEOUT_MS,
|
||||
timeout,
|
||||
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
|
||||
env,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,24 @@ import type { WindowsElevationStatus } from '@shared/types/api';
|
|||
|
||||
const DEFAULT_WINDOWS_ELEVATION_TIMEOUT_MS = 3_000;
|
||||
const DEFAULT_WINDOWS_SYSTEM_ROOT = 'C:\\Windows';
|
||||
const NON_ELEVATED_FLTMC_PATTERNS = [
|
||||
/\baccess\s+is\s+denied\b/i,
|
||||
/\baccess\s+denied\b/i,
|
||||
/\boperation\s+not\s+permitted\b/i,
|
||||
/requires?\s+elevation/i,
|
||||
/\belevated\b/i,
|
||||
/\badministrator\b/i,
|
||||
/\bprivileges?\b/i,
|
||||
/\bpermission\s+denied\b/i,
|
||||
/not\s+held\s+by\s+the\s+client/i,
|
||||
];
|
||||
const PROBE_UNAVAILABLE_PATTERNS = [
|
||||
/cannot\s+find/i,
|
||||
/not\s+found/i,
|
||||
/not\s+recognized/i,
|
||||
/not\s+a\s+valid\s+win32\s+application/i,
|
||||
/bad\s+exe\s+format/i,
|
||||
];
|
||||
|
||||
export interface WindowsElevationCommandResult {
|
||||
error: unknown;
|
||||
|
|
@ -22,6 +40,7 @@ export type WindowsElevationCommandRunner = (
|
|||
|
||||
export interface WindowsElevationStatusCheckerOptions {
|
||||
platform?: string;
|
||||
arch?: string;
|
||||
systemRoot?: string;
|
||||
timeoutMs?: number;
|
||||
runCommand?: WindowsElevationCommandRunner;
|
||||
|
|
@ -63,6 +82,10 @@ function wasKilledOrTimedOut(error: unknown): boolean {
|
|||
return killed === true || signal === 'SIGTERM' || code === 'ETIMEDOUT';
|
||||
}
|
||||
|
||||
function isMissingCommand(error: unknown): boolean {
|
||||
return getErrorCode(error) === 'ENOENT';
|
||||
}
|
||||
|
||||
function toCappedString(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
return value.slice(0, 500);
|
||||
|
|
@ -84,8 +107,50 @@ function getErrorMessage(error: unknown, stderr: unknown): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function getFltmcPath(systemRoot: string): string {
|
||||
return pathWin32.join(systemRoot, 'System32', 'fltmc.exe');
|
||||
function getCombinedErrorText(error: unknown, stderr: unknown): string {
|
||||
return [getErrorMessage(error, null), toCappedString(stderr)]
|
||||
.filter((part): part is string => Boolean(part?.trim()))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function looksLikeNonElevatedFltmcError(error: unknown, stderr: unknown): boolean {
|
||||
const code = getErrorCode(error);
|
||||
const combined = getCombinedErrorText(error, stderr);
|
||||
if (PROBE_UNAVAILABLE_PATTERNS.some((pattern) => pattern.test(combined))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (code === 1 || code === '1' || code === 5 || code === '5') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return NON_ELEVATED_FLTMC_PATTERNS.some((pattern) => pattern.test(combined));
|
||||
}
|
||||
|
||||
function normalizeWindowsRoot(value: string | null | undefined): string | null {
|
||||
const normalized = value?.trim().replace(/^['"]|['"]$/g, '');
|
||||
if (!normalized || !pathWin32.isAbsolute(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveWindowsSystemRoot(explicitSystemRoot: string | undefined): string {
|
||||
if (explicitSystemRoot !== undefined) {
|
||||
return normalizeWindowsRoot(explicitSystemRoot) ?? DEFAULT_WINDOWS_SYSTEM_ROOT;
|
||||
}
|
||||
|
||||
return (
|
||||
normalizeWindowsRoot(process.env.SystemRoot) ??
|
||||
normalizeWindowsRoot(process.env.windir) ??
|
||||
normalizeWindowsRoot(process.env.WINDIR) ??
|
||||
DEFAULT_WINDOWS_SYSTEM_ROOT
|
||||
);
|
||||
}
|
||||
|
||||
function getFltmcPathCandidates(systemRoot: string, arch: string): string[] {
|
||||
const systemDirs = arch === 'ia32' ? ['Sysnative', 'System32'] : ['System32'];
|
||||
return systemDirs.map((dir) => pathWin32.join(systemRoot, dir, 'fltmc.exe'));
|
||||
}
|
||||
|
||||
function runFltmc(command: string, options: WindowsElevationCommandOptions) {
|
||||
|
|
@ -105,7 +170,8 @@ export function createWindowsElevationStatusChecker(
|
|||
options: WindowsElevationStatusCheckerOptions = {}
|
||||
): () => Promise<WindowsElevationStatus> {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const systemRoot = options.systemRoot ?? process.env.SystemRoot ?? DEFAULT_WINDOWS_SYSTEM_ROOT;
|
||||
const arch = options.arch ?? process.arch;
|
||||
const systemRoot = resolveWindowsSystemRoot(options.systemRoot);
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_WINDOWS_ELEVATION_TIMEOUT_MS;
|
||||
const runCommand = options.runCommand ?? runFltmc;
|
||||
|
||||
|
|
@ -114,24 +180,36 @@ export function createWindowsElevationStatusChecker(
|
|||
return createStatus(platform, null, false);
|
||||
}
|
||||
|
||||
let result: WindowsElevationCommandResult;
|
||||
try {
|
||||
result = await runCommand(getFltmcPath(systemRoot), { timeoutMs });
|
||||
} catch (error) {
|
||||
return createStatus(platform, null, true, getErrorMessage(error, null));
|
||||
}
|
||||
for (const command of getFltmcPathCandidates(systemRoot, arch)) {
|
||||
let result: WindowsElevationCommandResult;
|
||||
try {
|
||||
result = await runCommand(command, { timeoutMs });
|
||||
} catch (error) {
|
||||
if (isMissingCommand(error)) {
|
||||
continue;
|
||||
}
|
||||
return createStatus(platform, null, true, getErrorMessage(error, null));
|
||||
}
|
||||
|
||||
if (!result.error) {
|
||||
return createStatus(platform, true, false);
|
||||
}
|
||||
if (!result.error) {
|
||||
return createStatus(platform, true, false);
|
||||
}
|
||||
|
||||
const message = getErrorMessage(result.error, result.stderr);
|
||||
if (isMissingCommand(result.error)) {
|
||||
continue;
|
||||
}
|
||||
if (wasKilledOrTimedOut(result.error)) {
|
||||
return createStatus(platform, null, true, message);
|
||||
}
|
||||
if (looksLikeNonElevatedFltmcError(result.error, result.stderr)) {
|
||||
return createStatus(platform, false, false, message);
|
||||
}
|
||||
|
||||
const code = getErrorCode(result.error);
|
||||
const message = getErrorMessage(result.error, result.stderr);
|
||||
if (code === 'ENOENT' || wasKilledOrTimedOut(result.error)) {
|
||||
return createStatus(platform, null, true, message);
|
||||
}
|
||||
|
||||
return createStatus(platform, false, false, message);
|
||||
return createStatus(platform, null, true, 'Windows elevation probe command was not found.');
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,22 @@ function createStatus(overrides: Partial<WindowsElevationStatus> = {}): WindowsE
|
|||
}
|
||||
|
||||
function installElevationStatus(status: WindowsElevationStatus) {
|
||||
const getWindowsElevationStatus = vi.fn().mockResolvedValue(status);
|
||||
return installElevationStatusPromise(Promise.resolve(status));
|
||||
}
|
||||
|
||||
function installElevationStatusPromise(promise: Promise<WindowsElevationStatus>) {
|
||||
const getWindowsElevationStatus = vi.fn().mockReturnValue(promise);
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getWindowsElevationStatus,
|
||||
},
|
||||
});
|
||||
return getWindowsElevationStatus;
|
||||
}
|
||||
|
||||
function installElevationStatusFailure() {
|
||||
const getWindowsElevationStatus = vi.fn().mockRejectedValue(new Error('IPC failed'));
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {
|
||||
|
|
@ -110,6 +125,73 @@ describe('WindowsAdministratorBanner', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('hides the warning when the status check is inconclusive', async () => {
|
||||
const getWindowsElevationStatus = installElevationStatus(
|
||||
createStatus({ isAdministrator: null, checkFailed: true, error: 'probe unavailable' })
|
||||
);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(WindowsAdministratorBanner));
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1);
|
||||
expect(host.textContent).toBe('');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushReact();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the warning when the status check rejects', async () => {
|
||||
const getWindowsElevationStatus = installElevationStatusFailure();
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(WindowsAdministratorBanner));
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1);
|
||||
expect(host.textContent).toBe('');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushReact();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render stale status after unmount', async () => {
|
||||
let resolveStatus: ((status: WindowsElevationStatus) => void) | null = null;
|
||||
const pendingStatus = new Promise<WindowsElevationStatus>((resolve) => {
|
||||
resolveStatus = resolve;
|
||||
});
|
||||
const getWindowsElevationStatus = installElevationStatusPromise(pendingStatus);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(WindowsAdministratorBanner));
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
resolveStatus?.(createStatus());
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1);
|
||||
expect(host.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('hides the warning when the preload bridge does not expose the status check', async () => {
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
|
|
|
|||
|
|
@ -1096,6 +1096,7 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
|
|||
expect.arrayContaining(['runtime', 'providers', 'view']),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(execCliMock.mock.calls[0]?.[2]).toMatchObject({ timeout: 90_000 });
|
||||
});
|
||||
|
||||
it('explains OpenCode CLI help output instead of returning a generic JSON error', async () => {
|
||||
|
|
@ -1274,7 +1275,7 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
|
|||
apiKey: 'sk-input-secret-value-123456',
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(45_000);
|
||||
await vi.advanceTimersByTimeAsync(90_000);
|
||||
const response = await responsePromise;
|
||||
vi.useRealTimers();
|
||||
|
||||
|
|
|
|||
|
|
@ -350,7 +350,7 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
if (normalizedArgs === 'runtime status --json --provider codex --summary') {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
'Command timed out after 25000ms: /mock/agent_teams_orchestrator runtime status --json --provider codex --summary'
|
||||
'Command timed out after 30000ms: /mock/agent_teams_orchestrator runtime status --json --provider codex --summary'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -370,7 +370,7 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
verificationState: 'error',
|
||||
statusMessage: 'Provider status unavailable',
|
||||
});
|
||||
expect(provider.detailMessage).toContain('Command timed out after 25000ms');
|
||||
expect(provider.detailMessage).toContain('Command timed out after 30000ms');
|
||||
expect(calls).toEqual(['runtime status --json --provider codex --summary']);
|
||||
expect(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual([
|
||||
expect.stringContaining(
|
||||
|
|
@ -386,7 +386,7 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
if (normalizedArgs === 'runtime status --json --provider opencode --summary') {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
'Command timed out after 25000ms: /mock/agent_teams_orchestrator runtime status --json --provider opencode --summary'
|
||||
'Command timed out after 30000ms: /mock/agent_teams_orchestrator runtime status --json --provider opencode --summary'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -412,7 +412,7 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
'not necessarily that OpenCode auth is missing'
|
||||
);
|
||||
expect(provider.detailMessage).toContain('provider/model inventory');
|
||||
expect(provider.detailMessage).toContain('Raw timeout detail: Command timed out after 25000ms');
|
||||
expect(provider.detailMessage).toContain('Raw timeout detail: Command timed out after 30000ms');
|
||||
expect(execCliMock.mock.calls.map((call) => call[1].join(' '))).toEqual([
|
||||
'runtime status --json --provider opencode --summary',
|
||||
]);
|
||||
|
|
@ -509,9 +509,9 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
|
||||
expect(execCliMock).toHaveBeenCalledTimes(3);
|
||||
expect(execCliMock.mock.calls.map((call) => call[2]?.timeout)).toEqual([
|
||||
15000,
|
||||
15000,
|
||||
15000,
|
||||
30000,
|
||||
30000,
|
||||
30000,
|
||||
]);
|
||||
expect(calls).toEqual([
|
||||
'runtime status --json --provider anthropic --summary',
|
||||
|
|
@ -895,6 +895,16 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
defaultModelId: 'gpt-5.4',
|
||||
},
|
||||
});
|
||||
expect(
|
||||
execCliMock.mock.calls.find(
|
||||
(call) => call[1].join(' ') === 'runtime status --json --provider codex --summary'
|
||||
)?.[2]?.timeout
|
||||
).toBe(30_000);
|
||||
expect(
|
||||
execCliMock.mock.calls.find(
|
||||
(call) => call[1].join(' ') === 'runtime status --json --provider codex'
|
||||
)?.[2]?.timeout
|
||||
).toBe(90_000);
|
||||
});
|
||||
|
||||
it('queues fresh single-provider catalog hydration behind an in-flight one', async () => {
|
||||
|
|
|
|||
|
|
@ -14,9 +14,24 @@ function createError(
|
|||
return Object.assign(new Error(message), fields);
|
||||
}
|
||||
|
||||
const originalSystemRoot = process.env.SystemRoot;
|
||||
const originalWindir = process.env.windir;
|
||||
const originalWINDIR = process.env.WINDIR;
|
||||
|
||||
function restoreEnvValue(name: 'SystemRoot' | 'windir' | 'WINDIR', value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
describe('windowsElevation', () => {
|
||||
afterEach(() => {
|
||||
resetWindowsElevationStatusCacheForTests();
|
||||
restoreEnvValue('SystemRoot', originalSystemRoot);
|
||||
restoreEnvValue('windir', originalWindir);
|
||||
restoreEnvValue('WINDIR', originalWINDIR);
|
||||
});
|
||||
|
||||
it('does not run the elevation command outside Windows', async () => {
|
||||
|
|
@ -72,6 +87,145 @@ describe('windowsElevation', () => {
|
|||
expect(status.error).toBe('Access is denied.');
|
||||
});
|
||||
|
||||
it('reports non-elevated Windows when fltmc returns an access-denied message', async () => {
|
||||
const runCommand = vi.fn<WindowsElevationCommandRunner>().mockResolvedValue({
|
||||
error: createError('Command failed', { code: 'EPERM' }),
|
||||
stderr: 'The requested operation requires elevation.',
|
||||
});
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(status.isAdministrator).toBe(false);
|
||||
expect(status.checkFailed).toBe(false);
|
||||
});
|
||||
|
||||
it('reports non-elevated Windows when stderr is a Buffer', async () => {
|
||||
const runCommand = vi.fn<WindowsElevationCommandRunner>().mockResolvedValue({
|
||||
error: createError('Command failed', { code: 'EPERM' }),
|
||||
stderr: Buffer.from('Access is denied.', 'utf8'),
|
||||
});
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(status.isAdministrator).toBe(false);
|
||||
expect(status.error).toBe('Access is denied.');
|
||||
});
|
||||
|
||||
it('uses the error message when stderr is empty', async () => {
|
||||
const runCommand = vi.fn<WindowsElevationCommandRunner>().mockResolvedValue({
|
||||
error: createError('operation not permitted', { code: 'EPERM' }),
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(status.isAdministrator).toBe(false);
|
||||
expect(status.error).toBe('operation not permitted');
|
||||
});
|
||||
|
||||
it('reports non-elevated Windows when fltmc returns Windows access-denied code 5', async () => {
|
||||
const runCommand = vi.fn<WindowsElevationCommandRunner>().mockResolvedValue({
|
||||
error: createError('Command failed', { code: 5 }),
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(status.isAdministrator).toBe(false);
|
||||
expect(status.checkFailed).toBe(false);
|
||||
});
|
||||
|
||||
it('passes a custom timeout to the Windows probe command', async () => {
|
||||
const runCommand = vi
|
||||
.fn<WindowsElevationCommandRunner>()
|
||||
.mockResolvedValue({ error: null });
|
||||
|
||||
await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
timeoutMs: 750,
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(runCommand).toHaveBeenCalledWith('C:\\Windows\\System32\\fltmc.exe', {
|
||||
timeoutMs: 750,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an unknown status when fltmc fails for an unrelated reason', async () => {
|
||||
const runCommand = vi.fn<WindowsElevationCommandRunner>().mockResolvedValue({
|
||||
error: createError('Unexpected fltmc failure', { code: 2 }),
|
||||
stderr: 'The system cannot find the file specified.',
|
||||
});
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(status.isAdministrator).toBeNull();
|
||||
expect(status.checkFailed).toBe(true);
|
||||
expect(status.error).toBe('The system cannot find the file specified.');
|
||||
});
|
||||
|
||||
it('caps long probe error text before returning it to the renderer', async () => {
|
||||
const longError = 'x'.repeat(600);
|
||||
const runCommand = vi.fn<WindowsElevationCommandRunner>().mockResolvedValue({
|
||||
error: createError('Unexpected fltmc failure', { code: 2 }),
|
||||
stderr: longError,
|
||||
});
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(status.isAdministrator).toBeNull();
|
||||
expect(status.error).toHaveLength(500);
|
||||
});
|
||||
|
||||
it('does not treat code 1 as non-elevated when the probe executable is unavailable', async () => {
|
||||
const runCommand = vi.fn<WindowsElevationCommandRunner>().mockResolvedValue({
|
||||
error: createError('Command failed', { code: 1 }),
|
||||
stderr: 'The system cannot find the file specified.',
|
||||
});
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(status.isAdministrator).toBeNull();
|
||||
expect(status.checkFailed).toBe(true);
|
||||
expect(status.error).toBe('The system cannot find the file specified.');
|
||||
});
|
||||
|
||||
it('does not treat code 1 as non-elevated when Windows cannot recognize the probe command', async () => {
|
||||
const runCommand = vi.fn<WindowsElevationCommandRunner>().mockResolvedValue({
|
||||
error: createError('Command failed', { code: 1 }),
|
||||
stderr: "'fltmc.exe' is not recognized as an internal or external command.",
|
||||
});
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(status.isAdministrator).toBeNull();
|
||||
expect(status.checkFailed).toBe(true);
|
||||
});
|
||||
|
||||
it('reports an unknown status when the Windows probe command is missing', async () => {
|
||||
const runCommand = vi.fn<WindowsElevationCommandRunner>().mockResolvedValue({
|
||||
error: createError('spawn fltmc.exe ENOENT', { code: 'ENOENT' }),
|
||||
|
|
@ -84,7 +238,23 @@ describe('windowsElevation', () => {
|
|||
|
||||
expect(status.isAdministrator).toBeNull();
|
||||
expect(status.checkFailed).toBe(true);
|
||||
expect(status.error).toContain('ENOENT');
|
||||
expect(status.error).toBe('Windows elevation probe command was not found.');
|
||||
});
|
||||
|
||||
it('continues to the next path when the probe command throws ENOENT', async () => {
|
||||
const runCommand = vi
|
||||
.fn<WindowsElevationCommandRunner>()
|
||||
.mockRejectedValueOnce(createError('spawn fltmc.exe ENOENT', { code: 'ENOENT' }))
|
||||
.mockResolvedValueOnce({ error: null });
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
arch: 'ia32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(runCommand).toHaveBeenCalledTimes(2);
|
||||
expect(status.isAdministrator).toBe(true);
|
||||
});
|
||||
|
||||
it('reports an unknown status when the Windows probe times out', async () => {
|
||||
|
|
@ -102,6 +272,170 @@ describe('windowsElevation', () => {
|
|||
expect(status.error).toContain('Command timed out');
|
||||
});
|
||||
|
||||
it('does not try the System32 fallback after a Sysnative timeout', async () => {
|
||||
const runCommand = vi.fn<WindowsElevationCommandRunner>().mockResolvedValue({
|
||||
error: createError('Command timed out', { code: 'ETIMEDOUT', killed: true }),
|
||||
});
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
arch: 'ia32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(runCommand).toHaveBeenCalledTimes(1);
|
||||
expect(status.isAdministrator).toBeNull();
|
||||
expect(status.checkFailed).toBe(true);
|
||||
});
|
||||
|
||||
it('tries the Sysnative fltmc path first for 32-bit Windows processes', async () => {
|
||||
const runCommand = vi
|
||||
.fn<WindowsElevationCommandRunner>()
|
||||
.mockResolvedValueOnce({
|
||||
error: createError('spawn fltmc.exe ENOENT', { code: 'ENOENT' }),
|
||||
})
|
||||
.mockResolvedValueOnce({ error: null });
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
arch: 'ia32',
|
||||
systemRoot: 'C:\\Windows',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(runCommand).toHaveBeenNthCalledWith(1, 'C:\\Windows\\Sysnative\\fltmc.exe', {
|
||||
timeoutMs: 3_000,
|
||||
});
|
||||
expect(runCommand).toHaveBeenNthCalledWith(2, 'C:\\Windows\\System32\\fltmc.exe', {
|
||||
timeoutMs: 3_000,
|
||||
});
|
||||
expect(status.isAdministrator).toBe(true);
|
||||
});
|
||||
|
||||
it('uses only System32 for non-32-bit Windows processes', async () => {
|
||||
const runCommand = vi
|
||||
.fn<WindowsElevationCommandRunner>()
|
||||
.mockResolvedValue({ error: null });
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
arch: 'arm64',
|
||||
systemRoot: 'C:\\Windows',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(runCommand).toHaveBeenCalledTimes(1);
|
||||
expect(runCommand).toHaveBeenCalledWith('C:\\Windows\\System32\\fltmc.exe', {
|
||||
timeoutMs: 3_000,
|
||||
});
|
||||
expect(status.isAdministrator).toBe(true);
|
||||
});
|
||||
|
||||
it('continues from missing Sysnative to a non-elevated System32 result', async () => {
|
||||
const runCommand = vi
|
||||
.fn<WindowsElevationCommandRunner>()
|
||||
.mockResolvedValueOnce({
|
||||
error: createError('spawn fltmc.exe ENOENT', { code: 'ENOENT' }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
error: createError('Command failed', { code: 5 }),
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
arch: 'ia32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(runCommand).toHaveBeenCalledTimes(2);
|
||||
expect(status.isAdministrator).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the default Windows root when SystemRoot is empty', async () => {
|
||||
const runCommand = vi
|
||||
.fn<WindowsElevationCommandRunner>()
|
||||
.mockResolvedValue({ error: null });
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
systemRoot: '',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(runCommand).toHaveBeenCalledWith('C:\\Windows\\System32\\fltmc.exe', {
|
||||
timeoutMs: 3_000,
|
||||
});
|
||||
expect(status.isAdministrator).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes quoted Windows root values', async () => {
|
||||
const runCommand = vi
|
||||
.fn<WindowsElevationCommandRunner>()
|
||||
.mockResolvedValue({ error: null });
|
||||
|
||||
await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
systemRoot: ' "D:\\Windows" ',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(runCommand).toHaveBeenCalledWith('D:\\Windows\\System32\\fltmc.exe', {
|
||||
timeoutMs: 3_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to windir when SystemRoot is unavailable', async () => {
|
||||
delete process.env.SystemRoot;
|
||||
process.env.windir = 'E:\\WinDir';
|
||||
const runCommand = vi
|
||||
.fn<WindowsElevationCommandRunner>()
|
||||
.mockResolvedValue({ error: null });
|
||||
|
||||
await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(runCommand).toHaveBeenCalledWith('E:\\WinDir\\System32\\fltmc.exe', {
|
||||
timeoutMs: 3_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to uppercase WINDIR when SystemRoot and windir are unavailable', async () => {
|
||||
delete process.env.SystemRoot;
|
||||
delete process.env.windir;
|
||||
process.env.WINDIR = 'F:\\Windows';
|
||||
const runCommand = vi
|
||||
.fn<WindowsElevationCommandRunner>()
|
||||
.mockResolvedValue({ error: null });
|
||||
|
||||
await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(runCommand).toHaveBeenCalledWith('F:\\Windows\\System32\\fltmc.exe', {
|
||||
timeoutMs: 3_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the default Windows root for relative SystemRoot values', async () => {
|
||||
const runCommand = vi
|
||||
.fn<WindowsElevationCommandRunner>()
|
||||
.mockResolvedValue({ error: null });
|
||||
|
||||
await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
systemRoot: 'Windows',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(runCommand).toHaveBeenCalledWith('C:\\Windows\\System32\\fltmc.exe', {
|
||||
timeoutMs: 3_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an unknown status when the Windows probe throws before returning a result', async () => {
|
||||
const runCommand = vi
|
||||
.fn<WindowsElevationCommandRunner>()
|
||||
|
|
|
|||
176
test/main/utils/windowsElevationRuntime.test.ts
Normal file
176
test/main/utils/windowsElevationRuntime.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('child_process')>();
|
||||
return {
|
||||
...actual,
|
||||
execFile: mocks.execFile,
|
||||
};
|
||||
});
|
||||
|
||||
type ExecFileCallback = (
|
||||
error: Error | null,
|
||||
stdout: string | Buffer,
|
||||
stderr: string | Buffer
|
||||
) => void;
|
||||
|
||||
function createError(
|
||||
message: string,
|
||||
fields: { code?: string | number; killed?: boolean; signal?: string | null } = {}
|
||||
): Error & { code?: string | number; killed?: boolean; signal?: string | null } {
|
||||
return Object.assign(new Error(message), fields);
|
||||
}
|
||||
|
||||
function setPlatform(value: string): void {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function setArch(value: string): void {
|
||||
Object.defineProperty(process, 'arch', {
|
||||
value,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
const originalArch = process.arch;
|
||||
const originalSystemRoot = process.env.SystemRoot;
|
||||
const originalWindir = process.env.windir;
|
||||
const originalWINDIR = process.env.WINDIR;
|
||||
|
||||
function restoreEnvValue(name: 'SystemRoot' | 'windir' | 'WINDIR', value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
async function importRuntime() {
|
||||
return import('@main/utils/windowsElevation');
|
||||
}
|
||||
|
||||
describe('windowsElevation runtime integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mocks.execFile.mockReset();
|
||||
setPlatform(originalPlatform);
|
||||
setArch(originalArch);
|
||||
restoreEnvValue('SystemRoot', originalSystemRoot);
|
||||
restoreEnvValue('windir', originalWindir);
|
||||
restoreEnvValue('WINDIR', originalWINDIR);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
setArch(originalArch);
|
||||
restoreEnvValue('SystemRoot', originalSystemRoot);
|
||||
restoreEnvValue('windir', originalWindir);
|
||||
restoreEnvValue('WINDIR', originalWINDIR);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not invoke execFile when the process platform is not Windows', async () => {
|
||||
setPlatform('linux');
|
||||
const { getWindowsElevationStatus } = await importRuntime();
|
||||
|
||||
const status = await getWindowsElevationStatus();
|
||||
|
||||
expect(mocks.execFile).not.toHaveBeenCalled();
|
||||
expect(status).toEqual({
|
||||
platform: 'linux',
|
||||
isWindows: false,
|
||||
isAdministrator: null,
|
||||
checkFailed: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('runs fltmc with hidden-window timeout options on Windows', async () => {
|
||||
setPlatform('win32');
|
||||
setArch('x64');
|
||||
process.env.SystemRoot = 'C:\\Windows';
|
||||
mocks.execFile.mockImplementation(
|
||||
(_command: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(null, '', '');
|
||||
}
|
||||
);
|
||||
const { getWindowsElevationStatus } = await importRuntime();
|
||||
|
||||
const status = await getWindowsElevationStatus();
|
||||
|
||||
expect(mocks.execFile).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.execFile).toHaveBeenCalledWith(
|
||||
'C:\\Windows\\System32\\fltmc.exe',
|
||||
[],
|
||||
{ timeout: 3_000, windowsHide: true },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(status.isAdministrator).toBe(true);
|
||||
});
|
||||
|
||||
it('coalesces concurrent status requests into one Windows subprocess', async () => {
|
||||
setPlatform('win32');
|
||||
setArch('x64');
|
||||
const captured: { callback?: ExecFileCallback } = {};
|
||||
mocks.execFile.mockImplementation(
|
||||
(_command: string, _args: string[], _options: unknown, nextCallback: ExecFileCallback) => {
|
||||
captured.callback = nextCallback;
|
||||
}
|
||||
);
|
||||
const { getWindowsElevationStatus } = await importRuntime();
|
||||
|
||||
const first = getWindowsElevationStatus();
|
||||
const second = getWindowsElevationStatus();
|
||||
|
||||
expect(mocks.execFile).toHaveBeenCalledTimes(1);
|
||||
expect(captured.callback).toBeTypeOf('function');
|
||||
captured.callback?.(null, '', '');
|
||||
await expect(first).resolves.toMatchObject({ isAdministrator: true });
|
||||
await expect(second).resolves.toMatchObject({ isAdministrator: true });
|
||||
});
|
||||
|
||||
it('reuses the cached result after the first Windows probe completes', async () => {
|
||||
setPlatform('win32');
|
||||
setArch('x64');
|
||||
mocks.execFile.mockImplementation(
|
||||
(_command: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(createError('Command failed', { code: 5 }), '', '');
|
||||
}
|
||||
);
|
||||
const { getWindowsElevationStatus } = await importRuntime();
|
||||
|
||||
const first = await getWindowsElevationStatus();
|
||||
const second = await getWindowsElevationStatus();
|
||||
|
||||
expect(mocks.execFile).toHaveBeenCalledTimes(1);
|
||||
expect(first).toEqual(second);
|
||||
expect(second.isAdministrator).toBe(false);
|
||||
});
|
||||
|
||||
it('runs a new probe after the cache is reset for tests', async () => {
|
||||
setPlatform('win32');
|
||||
setArch('x64');
|
||||
mocks.execFile.mockImplementation(
|
||||
(_command: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(null, '', '');
|
||||
}
|
||||
);
|
||||
const { getWindowsElevationStatus, resetWindowsElevationStatusCacheForTests } =
|
||||
await importRuntime();
|
||||
|
||||
await getWindowsElevationStatus();
|
||||
resetWindowsElevationStatusCacheForTests();
|
||||
await getWindowsElevationStatus();
|
||||
|
||||
expect(mocks.execFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -41,6 +41,7 @@ describe('preload electronAPI memberWorkSync wiring', () => {
|
|||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
mocks.contextBridge.exposeInMainWorld.mockClear();
|
||||
mocks.ipcRenderer.invoke.mockClear();
|
||||
mocks.createMemberWorkSyncBridge.mockClear();
|
||||
});
|
||||
|
||||
|
|
@ -64,4 +65,24 @@ describe('preload electronAPI memberWorkSync wiring', () => {
|
|||
expect(apiName).toBe('electronAPI');
|
||||
expect(electronAPI.memberWorkSync).toBe(mocks.memberWorkSyncBridge);
|
||||
});
|
||||
|
||||
it('wires the Windows elevation status API to the app IPC channel', async () => {
|
||||
await import('../../src/preload/index');
|
||||
|
||||
const [, electronAPI] = mocks.contextBridge.exposeInMainWorld.mock.calls[0] as [
|
||||
string,
|
||||
ElectronAPI,
|
||||
];
|
||||
const expectedStatus = {
|
||||
platform: 'win32',
|
||||
isWindows: true,
|
||||
isAdministrator: false,
|
||||
checkFailed: false,
|
||||
error: null,
|
||||
};
|
||||
mocks.ipcRenderer.invoke.mockResolvedValueOnce(expectedStatus);
|
||||
|
||||
await expect(electronAPI.getWindowsElevationStatus()).resolves.toBe(expectedStatus);
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith('app:getWindowsElevationStatus');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue