mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
fix(terminal): re-verify cached macOS login-preflight rejections
A conclusive PAM rejection was cached for the process lifetime, so one false verdict (the probe runs over pipes, not a PTY) disabled the login(1) TCC attribution wrapper for a daemon that survives app quits and updates for weeks — reintroducing the every-invocation AppData prompts #7003 fixed. Rejections now re-verify after 30 minutes; accepted verdicts still cache for the process lifetime. Refs #9756
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<LoginPreflightOutcome> | null = null
|
||||
let transientLoginPreflightFailure: { failureCount: number; retryAtMs: number } | null = null
|
||||
|
||||
@@ -103,6 +109,17 @@ function runLoginPreflight(username: string, accountHome: string): Promise<Login
|
||||
})
|
||||
}
|
||||
|
||||
function expireStaleRejectedVerdict(): void {
|
||||
if (
|
||||
cachedLoginPreflightResult === false &&
|
||||
cachedRejectionAtMs !== null &&
|
||||
Date.now() - cachedRejectionAtMs >= 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<LoginPreflightOutcome
|
||||
if (process.platform !== 'darwin' || isDisabledByEnv()) {
|
||||
return null
|
||||
}
|
||||
expireStaleRejectedVerdict()
|
||||
if (cachedLoginPreflightResult !== null) {
|
||||
return null
|
||||
}
|
||||
@@ -190,6 +211,7 @@ export async function prepareMacosTccLoginShell(): Promise<LoginPreflightOutcome
|
||||
|
||||
export function resetMacosLoginShellPreflightForTests(): void {
|
||||
cachedLoginPreflightResult = null
|
||||
cachedRejectionAtMs = null
|
||||
loginPreflightInFlight = null
|
||||
transientLoginPreflightFailure = null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user