diff --git a/src/main/providers/macos-tcc-login-shell.test.ts b/src/main/providers/macos-tcc-login-shell.test.ts index 565d40da9dd..7a7d3a36cde 100644 --- a/src/main/providers/macos-tcc-login-shell.test.ts +++ b/src/main/providers/macos-tcc-login-shell.test.ts @@ -116,6 +116,53 @@ describe('wrapShellSpawnForMacosTccAttribution', () => { expect(console.warn).toHaveBeenCalledTimes(1) }) + it('re-verifies a cached PAM rejection after the revalidation window (#9756)', async () => { + setPlatform('darwin') + let now = 1_000 + vi.spyOn(Date, 'now').mockImplementation(() => now) + let attempt = 0 + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + attempt += 1 + if (attempt === 1) { + // A one-off PAM hiccup that login(1) reports as a deterministic rejection. + callback(Object.assign(new Error('login incorrect'), { code: 1 }), '', '') + } else { + callback(null, 'ORCA_LOGIN_PREFLIGHT_OK', '') + } + return { stdin: { end: stdinEndMock } } + } + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await prepareMacosTccLoginShell() + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') + + // Inside the window the rejection stays cached — no probe per spawn. + now += 29 * 60_000 + await prepareMacosTccLoginShell() + expect(execFileMock).toHaveBeenCalledTimes(1) + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') + + // Past the window the daemon re-probes instead of staying degraded forever. + now += 60_000 + await prepareMacosTccLoginShell() + expect(execFileMock).toHaveBeenCalledTimes(2) + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') + }) + + it('keeps an accepted PAM verdict cached across the rejection revalidation window', async () => { + setPlatform('darwin') + let now = 1_000 + vi.spyOn(Date, 'now').mockImplementation(() => now) + + await prepareMacosTccLoginShell() + now += 24 * 60 * 60_000 + await prepareMacosTccLoginShell() + expect(execFileMock).toHaveBeenCalledTimes(1) + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') + }) + it('backs off repeated transient timeouts instead of delaying every terminal spawn (F1)', async () => { setPlatform('darwin') let now = 1_000 diff --git a/src/main/providers/macos-tcc-login-shell.ts b/src/main/providers/macos-tcc-login-shell.ts index bd92cacaac5..186b9931de8 100644 --- a/src/main/providers/macos-tcc-login-shell.ts +++ b/src/main/providers/macos-tcc-login-shell.ts @@ -10,6 +10,11 @@ const LOGIN_PREFLIGHT_MARKER = 'ORCA_LOGIN_PREFLIGHT_OK' const LOGIN_PREFLIGHT_MAX_BUFFER_BYTES = 1024 const LOGIN_PREFLIGHT_RETRY_BASE_MS = 5_000 const LOGIN_PREFLIGHT_RETRY_MAX_MS = 5 * 60_000 +// Why: daemons live for weeks across app updates, so one false PAM rejection (probe +// runs over pipes, not a PTY) must not disable TCC attribution forever (#9756); +// re-verify on a slow cadence — a genuinely rejecting host only re-pays one 500ms +// probe per window, while an accepted verdict stays cached for the process lifetime. +const LOGIN_PREFLIGHT_REJECTED_REVALIDATE_MS = 30 * 60_000 /** * Env escape hatch to force the plain (unwrapped) spawn. Set to `1`/`true` if a @@ -30,6 +35,7 @@ export type LoginPreflightOutcome = { } let cachedLoginPreflightResult: boolean | null = null +let cachedRejectionAtMs: number | null = null let loginPreflightInFlight: Promise | null = null let transientLoginPreflightFailure: { failureCount: number; retryAtMs: number } | null = null @@ -103,6 +109,17 @@ function runLoginPreflight(username: string, accountHome: string): Promise= LOGIN_PREFLIGHT_REJECTED_REVALIDATE_MS + ) { + cachedLoginPreflightResult = null + cachedRejectionAtMs = null + } +} + function cachedOutcome(): LoginPreflightOutcome | null { if (cachedLoginPreflightResult === null) { return null @@ -128,6 +145,7 @@ function loginPreflightSucceeds( // environmental and must be retried next spawn, not stuck forever (F1). if (outcome.conclusive) { cachedLoginPreflightResult = outcome.ok + cachedRejectionAtMs = outcome.ok ? null : Date.now() transientLoginPreflightFailure = null } else { const failureCount = (transientLoginPreflightFailure?.failureCount ?? 0) + 1 @@ -149,7 +167,9 @@ function loginPreflightSucceeds( } /** - * Resolves the one-time PAM capability check before a fresh PTY is spawned. + * Resolves the cached PAM capability check before a fresh PTY is spawned. + * Accepted verdicts stick for the process lifetime; rejected verdicts are + * re-verified after {@link LOGIN_PREFLIGHT_REJECTED_REVALIDATE_MS}. * Callers await this at their async request boundary so existing terminals and * the Electron main thread remain responsive while login(1) runs. * @@ -162,6 +182,7 @@ export async function prepareMacosTccLoginShell(): Promise