Fail-open accept tap: never consume bare printables, never consume without verified session#382
Conversation
…thout verified session
| let isBarePrintable = eventModifiers.isEmpty | ||
| && !event.unicodeString.trimmingCharacters(in: .controlCharacters).isEmpty | ||
| if isBarePrintable { | ||
| let message = "Accept tap refusing to consume bare printable keyCode=\(keyCode). " | ||
| + "Rebind with a modifier in Settings > Shortcuts." | ||
| CotabbyLogger.app.warning("\(message)") | ||
| return Unmanaged.passUnretained(event) | ||
| } |
There was a problem hiding this comment.
Double-action when a bare-printable binding matches an active session
Layer 1 prevents the accept tap from consuming the key, but it cannot prevent the observer tap (which fires first, at the head) from having already classified the same keystroke as .acceptance/.fullAcceptance and driven the full acceptance flow through acceptSuggestion. When a session is active, acceptSuggestion inserts the suggestion text and calls hideOverlay — all before the accept tap even runs. Layer 1 then passes the key through to Chrome.
Concrete failure: user binds bare a, types a with a visible suggestion. The observer fires, acceptSuggestion succeeds and inserts the AI text. Then the accept tap fires: isBarePrintable is true, so a also reaches Chrome. The user sees the suggestion text plus a literal a appended.
The fix would be to also guard classify() (or handleObserverTap) so that it does not emit .acceptance/.fullAcceptance when the binding is a bare printable — or to gate acceptCurrentSuggestion/acceptEntireSuggestion on shouldConsumeAcceptKeyProvider() directly.
| guard shouldConsumeAcceptKeyProvider() else { | ||
| let message = "Accept tap declining to consume keyCode=\(keyCode): " | ||
| + "coordinator reports no ready session" | ||
| CotabbyLogger.app.debug("\(message)") | ||
| return Unmanaged.passUnretained(event) | ||
| } |
There was a problem hiding this comment.
Layer 2 reads
overlayState that was updated by the observer callback for the same event
The observer tap fires first (head-inserted), potentially drives a successful acceptance that calls hideOverlay() → setAcceptInterceptionActive(false) → destroyAcceptTap(), and returns. overlayState is now .hidden. When the accept tap fires next, shouldConsumeAcceptKeyProvider() reads self.overlayState.isVisible = false and returns false, passing the accept key through.
Whether this actually regresses the happy path (Tab/⌃Tab) depends on whether CFMachPortInvalidate prevents the accept tap from being invoked at all for the in-flight event. If the callback is not invoked after invalidation, this is benign; if it is, every successful acceptance would also deliver the accept key to the focused app. The PR's test 4 claim ("keystroke does not reach the host") should be validated against a case where the accept tap is still installed (i.e., where destroyAcceptTap is NOT called during the observer callback).
Root cause
User report: typing regular letters/numbers in Chrome with a Cotabby suggestion visible silently drops the character. Three subagent investigations converged on the same structural cause introduced by #337 (split-tap model):
The active accept tap (.defaultTap, tail) returns nil and swallows any keystroke whose (keyCode, modifiers) matches the user's bound accept binding. The matching is correct in isolation, but:
Fix (two layers, both fail-open)
Plus debug logging on every consume decision so future swallow reports can be diagnosed from the runtime log.
Why this prevents the symptom
Test plan
Risk / rollout notes
Greptile Summary
This PR adds two fail-open guards to the active accept tap: Layer 1 refuses to consume keystrokes whose binding has no modifiers and produces a printable character, and Layer 2 delegates the consume decision to a new
shouldConsumeAcceptKeyProviderclosure wired inSuggestionCoordinatorto a live three-condition predicate (state.ready+ active session + visible overlay).InputMonitor.swift:handleAcceptTapgains the two guard layers and structured debug/warning logging for every consume decision.SuggestionSubsystemContracts.swift:shouldConsumeAcceptKeyProvider: @MainActor () -> Booladded toSuggestionInputMonitoring, defaulting to{ false }onInputMonitorso stale installs are automatically fail-open.SuggestionCoordinator.swift: The provider closure is wired atinittime before any overlay callbacks, using[weak self]so deallocation also collapses tofalse.Confidence Score: 3/5
The two-tap architecture means the observer tap always drives acceptance independently of what the accept tap subsequently decides — Layer 1 prevents consumption but cannot un-do the acceptance already triggered by the observer for the same event.
The Layer 2 wiring is correct and the fail-open default is safe. Layer 1's bare-printable guard creates an observable double-action for any user with a misconfigured bare-printable binding: the observer tap classifies the keypress as
.acceptanceand drives suggestion insertion before the accept tap even runs, so acceptance happens AND the character reaches Chrome. Additionally, in the normal acceptance success path the observer hides the overlay (updatingoverlayState) before the accept tap fires, so Layer 2 readsoverlayState.isVisible = false— whether this causes the accept key to leak to the focused app depends on undocumentedCFMachPortInvalidatein-flight semantics that should be validated empirically.Cotabby/Services/Input/InputMonitor.swift — both the Layer 1 guard in handleAcceptTap and the Layer 2 overlayState timing need a second look before merge.
Important Files Changed
Sequence Diagram
sequenceDiagram participant User participant macOS participant ObserverTap as Observer Tap (head, listen-only) participant Coordinator as SuggestionCoordinator participant AcceptTap as Accept Tap (tail, active) participant Chrome User->>macOS: keyDown (accept key) macOS->>ObserverTap: event delivered (1st) ObserverTap->>Coordinator: classify → onEvent(.acceptance) Coordinator->>Coordinator: acceptSuggestion() Note over Coordinator: Guards: session≠nil, state==.ready, overlay visible Coordinator->>Coordinator: insert suggestion text Coordinator->>Coordinator: hideOverlay → destroyAcceptTap() ObserverTap-->>macOS: passUnretained (always) macOS->>AcceptTap: event delivered (2nd) Note over AcceptTap: Layer 1: isBarePrintable? alt "isBarePrintable == true" AcceptTap-->>macOS: passUnretained (key NOT consumed) macOS->>Chrome: key delivered else "isBarePrintable == false" Note over AcceptTap: Layer 2: shouldConsumeAcceptKeyProvider() alt session ready + active + overlay visible AcceptTap-->>macOS: nil (key consumed) else any condition false AcceptTap-->>macOS: passUnretained (key NOT consumed) macOS->>Chrome: key delivered end endReviews (1): Last reviewed commit: "Fail-open accept tap: never consume bare..." | Re-trigger Greptile