fix(cmux-sync): stop SecurityAgent prompt storm on go install builds - #107
Conversation
go install produces ad-hoc signed binaries with no team ID. On those, the CGO SecItemCopyMatching path always triggers a blocking GUI SecurityAgent dialog, and three compounding bugs turned a single `cmux-sync enable` into 10+ prompts in rapid succession: 1. safeStoragePasswordViaKeybaseFor spawns a goroutine for SecItemCopyMatching, times out after 3s and abandons it — the dialog stays open while the security-CLI fallback opens a second one. Two prompts per Keychain read. 2. The launch agent's KeepAlive.SuccessfulExit:false restarts cmux-sync every 10s on the non-zero exit a Keychain denial produces — more prompts forever. 3. cmux-sync enable installs the persistent agent with no Keychain pre-flight, so the restart loop starts before the user can grant access. Fixes: - U1: SafeStoragePasswordFor checks BinaryTeamID(os.Executable()); when empty (ad-hoc/unsigned), skip the CGO path entirely and go straight to the security CLI, avoiding both the initial prompt and the leaked goroutine. - U2: in --watch mode, exit 0 (not non-zero) when SafeStoragePasswordFor returns a Keychain access error, so launchd's KeepAlive does not restart the agent into a prompt loop. Operational errors still exit non-zero. Adds chrome.IsKeychainAccessError to classify the boundary. - U3: enableCmuxLoop runs a Keychain pre-flight before installing the agent and aborts with remediation if it fails, so the loop never starts. The pre-flight runs after the cmux-not-found no-op, so absent cmux never prompts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Greptile SummaryThree compounding bugs caused repeated SecurityAgent Keychain prompts on
Confidence Score: 4/5Safe to merge for the core prompt-storm fix; one ordering issue in the enable flow leaves cmux.json in The fix for unsigned binaries, the internal/cli/cmux_sync_enable.go — the ordering of Important Files Changed
|
…rdering Two findings from PR review: - IsKeychainAccessError no longer treats a locked keychain (-25308 "User interaction is not allowed") as a missing grant. That error is transient (screen-lock / sleep-wake while the login keychain relocks) and resolves on its own, so --watch must exit non-zero and let launchd's KeepAlive retry rather than exit 0 and stop the sync permanently. Only the genuine missing-grant strings now map to exit 0. - Move the enable Keychain pre-flight to after the cmux.json ErrNotFound guard, immediately before installing the agent. A freshly installed but never-launched cmux now surfaces "launch cmux once" instead of a misleading "grant Keychain access" message. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ble side-effect (follow-up to #107) (#108) * fix(chrome): suppress keychain ACL dialog and drop the leaking goroutine The CGO Safe Storage read ran SecItemCopyMatching in a goroutine abandoned on a 3s timeout; on a signed binary that lost its grant the abandoned call held a SecurityAgent dialog open and the security CLI fallback opened a second one. Disable keychain user interaction (SecKeychainSetUserInteractionAllowed) around the call so it returns errSecInteractionNotAllowed immediately instead of prompting, making the call synchronous -- no goroutine to abandon, no orphaned dialog. Replaces the keybase convenience wrapper with a direct SecItem query so the interaction toggle and a no-prompt fast path can be expressed. kSecUseAuthenticationUIFail alone does not suppress this prompt: the Chrome Safe Storage dialog is a legacy keychain ACL confirmation, not a LocalAuthentication UI, so the interaction toggle is what matters. * fix(chrome): classify keychain failures by sentinel, not wrapper prose SafeStoragePasswordFor wrapped every non-timeout security-CLI failure as "did you grant access?" and IsKeychainAccessError string-matched that wrapper, so a transient locked keychain was misread as a permanent missing grant -- and --watch exited 0 (no launchd retry), permanently stopping the sync. The raw-25308 exclusion PR #107 added never fired because the live path discards the raw error and re-wraps it. Introduce ErrKeychainNoGrant/ErrKeychainLocked/ErrKeychainTimeout, capture the security CLI stderr, classify at the source, and wrap with %w. IsKeychainAccessError is now pure errors.Is (locked errors are excluded even when wrapped in grant-prose); IsKeychainLocked matches the sentinel plus the legacy -25308 string so foreign callers (doctor, SSH reads) still work. Regression test asserts a locked error wrapped in grant-prose is not an access error and that the sentinel survives an extra wrap layer. * fix(cli): run cmux-sync enable Keychain pre-flight before mutating cmux.json enableCmuxLoop wrote socketControlMode=allowAll (via cmuxSyncSetMode) before the Keychain pre-flight, so a failed enable left cmux.json mutated with an orphaned allowAll and a stray .bak. Add a stubbable cmuxSyncConfigExists seam to surface the cmux.json-missing no-op, then run the pre-flight, then write -- so an aborted enable leaves cmux.json untouched. The ErrNotFound branch stays as a TOCTOU guard. Tests assert no socketControlMode write on pre-flight failure or missing config. * fix(chrome): detect locked keychain before the security CLI; guard interaction flip Adversarial review surfaced two issues in the hardening: - A locked login keychain can make the security CLI hang on an unlock prompt until the timeout, which classified as ErrKeychainTimeout -> exit 0 -- recreating the permanent-stop bug through the timeout door (and naively retrying on timeout would instead stack unlock prompts). Add keychainDefaultLocked (SecKeychainGetStatus) and consult it before the CLI read: a locked keychain short-circuits to ErrKeychainLocked so --watch exits non-zero and launchd retries, while the up-front check keeps retries fast (no CLI hang, no prompt stacking). Unknown lock state (status error / non-cgo) falls through to the CLI unchanged. - SecKeychainSetUserInteractionAllowed is process-global; serialize the flip with a package mutex so a concurrent in-process keychain caller never observes it half-flipped. Also tighten keychainLockedSignal from "25308" to "-25308" to avoid matching unrelated numerics in CLI stderr. * fix(chrome): guard CF NULL returns and gofmt test alignment Greptile: CFStringCreateWithBytes and CFDictionaryCreate return NULL on allocation failure, and CFRelease(NULL) is undefined behavior -- guard each before deferring the release. Also fix gofmt comment alignment in keychain_test.go that failed go-lint. * test(cli): rename keychainAccessErr to errKeychainAccess (staticcheck ST1012) Error vars must use the errFoo form; the var became an error value when it started carrying the ErrKeychainNoGrant sentinel. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Closes #106
What this fixes
Three compounding bugs caused
agentcookie cmux-sync enableto trigger 10+ SecurityAgent Keychain prompts in rapid succession ongo installbuilds (ad-hoc signed, no team ID). See #106 for the full repro and root-cause analysis.Changes
internal/chrome/keychain.go(U1):SafeStoragePasswordFornow checksBinaryTeamID(os.Executable())before the CGO path. When it returns""(ad-hoc/unsigned binary), it skipssafeStoragePasswordViaKeybaseFor(SecItemCopyMatching) entirely and goes straight to thesecurityCLI. Unsigned binaries always prompt on the CGO path; skipping it avoids both the initial SecurityAgent dialog and the goroutine leaked after the 3s timeout. Signed binaries are unchanged. AddsIsKeychainAccessErrorto classify Keychain-grant failures vs. operational failures. The CGO call is now behind asafeStorageViaKeybaseRunnerseam for testability.internal/cli/cmux_sync.go(U2): In--watchmode, whenSafeStoragePasswordForreturns a Keychain access error, the agent prints the remediation and exits 0 instead of returning the error — so launchd'sKeepAlivedoes not restart it into a prompt loop. Operational errors (cmux down, Chrome DB locked, etc.) still exit non-zero so launchd retries them as before.--oncemode is unchanged (still returns the error).internal/cli/cmux_sync_enable.go(U3):enableCmuxLoopruns a Keychain pre-flight before installing the persistent launch agent and aborts withwizard set-keychain-accessremediation if it fails — so the restart loop never starts. The pre-flight runs after the cmux-not-found no-op, so an absent cmux never triggers a prompt. With U1 in place, on a granted machine the pre-flight and the agent's own read both go through thesecurityCLI with no prompt; on an ungranted machine the pre-flight aborts and the agent is never installed.Tests
internal/chrome/keychain_test.go: CGO path is skipped for unsigned binaries, used for signed binaries, and aBinaryTeamIDerror is treated as unsigned;IsKeychainAccessErrorboundary cases.internal/cli/cmux_sync_test.go:--watchexits 0 on Keychain access error, returns error on non-Keychain errors, and--oncereturns the error (no exit-0).internal/cli/cmux_sync_enable_test.go: enable aborts (no mode change, no agent install) when pre-flight fails, proceeds when it passes, and skips the pre-flight entirely when cmux is absent.go test ./...passes.Notes / scope
BinaryTeamIDshells out tocodesign; it runs once per process in these call paths, so the added latency is negligible (not cached). Worth async.Onceonly ifSafeStoragePasswordForever becomes hot.go install) case, which is the storm in cmux-sync enable triggers 10+ SecurityAgent Keychain prompts in rapid succession (go install builds) #106. A Developer-ID-signed binary that loses its grant (e.g., after a macOS upgrade) would still take the CGO path; the underlying goroutine-abandonment insafeStoragePasswordViaKeybaseForis out of scope here and could be hardened separately.🤖 Generated with Claude Code