fix: show pending refs in custom NSWindow at TouchID prompt (op-c0e) - #7
Conversation
Implements Option 1 from the op-knn design: a custom NSPanel floats above the terminal listing every op:// ref involved in the upcoming TouchID auth. The system LAContext dialog appears on top of it, unchanged. This mirrors the pattern used by 1Password, pinentry-mac, and ssh-askpass. Why not the prior approaches: - op-rba (stdout prints, PR #5 revert b1a18f0): polluted piped output. - original op-c0e (reason-string, PR #5 revert 699977d): cramped single line in the system dialog, and broke CI tests. The window is shown only when `pendingRefs` has entries (cmdRead, cmdInject, cmdRun populate it). A fresh session with a valid session token skips biometric auth entirely, so no window is shown. The list is deduped while preserving order. touchid.m links AppKit and uses NSApplicationActivationPolicyAccessory so the process has no dock icon or menu bar. CFRunLoopRunInMode drives the window's rendering while we block on the LAContext reply. The test-path stub (touchid_stub.m, built under `-tags test`) adds the new `refsText` parameter but ignores it — no AppKit linkage, so headless CI builds remain unaffected. Refs: op-knn (design), PR #5 (the revert), op-rba (earlier attempt). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
jeremyschlatter
left a comment
There was a problem hiding this comment.
The official op CLI creates custom TouchID prompt windows that look like this:
By contrast, this PR causes us to end up with two windows on top of each other like this:
The 1-window way is clearly better. Figure out how to do that instead. If helpful, here is a guess from Opus 4.6 about how the op implementation works. Keep in mind it is just a guess and needs to be confirmed:
Technical Brief: How 1Password Achieves a Single-Window TouchID Experience
The Theory: Sheet Presentation
1Password's TouchID dialog appears as a single cohesive window, but it's actually two elements composed together: a custom NSWindow owned by 1Password showing context (app icon, "Allow X
to get CLI access", account selector), with the system LAContext biometric prompt presented as a sheet attached to that window. macOS sheets slide down from the parent window's title
bar and share its chrome — corners, shadow, background — making parent+sheet look like one unified panel.
Technical Mechanism
When a macOS app calls LAContext.evaluatePolicy(_:localizedReason:), the system's SecurityAgent process renders the TouchID dialog. The presentation mode depends on the caller's
application state:
- No foreground app / no key window (typical for CLI tools): SecurityAgent renders a floating system modal — a standalone dialog centered on screen. This is what PR #7 currently
produces. - Foreground app with an active key window: SecurityAgent can attach the dialog as a document-modal sheet to the calling app's key window. The sheet's corner radius and shadow merge with
the parent window, producing the unified appearance 1Password shows.
The suspected key requirements:
- NSApplication initialized with NSApplicationActivationPolicyRegular (not .Accessory) — the app must be a "real" foreground app, not a background utility
- [NSApp activateIgnoringOtherApps:YES] to claim foreground status
- The custom NSWindow must be makeKeyAndOrderFront: before calling evaluatePolicy:
- The evaluatePolicy: call must happen on the main thread while the window is key
Evidence
- The 1Password screenshot shows uniform corner radii and continuous shadow between the context area (app icons, account name) and the biometric area (fingerprint icon, "Use Password"
button). This is visually consistent with a sheet presentation, not two overlapping windows. - The macOS Human Interface Guidelines document sheet presentation as the standard pattern for auth dialogs originating from a specific window context.
- PR #7's current approach uses NSApplicationActivationPolicyAccessory and creates an NSPanel with utility-window styling. This explicitly opts out of foreground-app status, which would
prevent SecurityAgent from attaching its dialog as a sheet.
What Was Ruled Out
- Customizing the LAContext dialog itself: dead end. The dialog is rendered by SecurityAgent in a separate process. No public or private API can change its layout, add views, or restyle
it. (Confirmed in op-knn design doc, Option 2.) - Alternative biometric frameworks (CryptoTokenKit, ASAuthorization): all funnel through the same SecurityAgent UI. No advantage. (op-knn Option 3.)
- LocalAuthenticationEmbeddedUI / LAAuthenticationView (macOS 14+): a newer framework that embeds biometric UI inline as a SwiftUI view. This is a viable alternative path if sheet
presentation doesn't work, but would require a SwiftUI dependency and macOS 14 minimum.
Open Questions
- Whether NSApplicationActivationPolicyRegular from a CLI-launched process actually works, or whether macOS requires an app bundle (Info.plist, proper CFBundleIdentifier) for foreground
activation. - If sheet presentation fails, LAAuthenticationView (macOS 14+) is the fallback — but its availability on the user's minimum supported macOS version is unknown.
Rewrites touchid.m to render the biometric UI *inside* our own NSWindow using LocalAuthenticationEmbeddedUI's LAAuthenticationView (macOS 12+). This replaces the PR #7 rev 1 approach (separate NSPanel + system LAContext modal), which the reviewer rejected for its two-window look. ## Why this matches the 1Password target Empirical testing on macOS 14.6.1 confirmed the sheet-presentation theory from the PR review does NOT work: SecurityAgent renders the LAContext modal as a floating dialog regardless of NSApplication activation policy (.Accessory or .Regular), key-window state, or activateIgnoringOtherApps:. It's always a separate card. LAAuthenticationView is Apple's supported way to draw the biometric UI inline. The view is an NSView that pairs with an LAContext; when you call evaluatePolicy: on that context, the TouchID icon/scan renders *inside* the view rather than spawning the system modal. Since the view lives inside our NSWindow, the result is a single unified card — the same pattern 1Password's desktop app uses. ## Flow 1. `[NSApp setActivationPolicy:Regular]` + `[NSApp run]` — required for the NSApp instance to be a proper foreground UI app so LAContext doesn't reject with LAErrorUserInteractionRequired. 2. Inside applicationDidFinishLaunching: we create the NSWindow with a header, a scrollable refs list, and an LAAuthenticationView at the bottom. 3. We call evaluatePolicy: with biometrics policy on the same LAContext instance the view is paired with. The biometric UI renders inline. 4. On auth completion (success or failure), we close the window and [NSApp stop:] + post a wake event so the run loop returns. ## Fallback LAAuthenticationView only supports biometric-ish policies. If canEvaluatePolicy:WithBiometrics fails (no Touch ID hardware, or the session has no paired biometric accessory), we fall back to the classic LAContext.evaluatePolicy with device-owner auth — no window, system password dialog. Functional but unstyled. Acceptable for the minority of users without biometric hardware. ## Build Adds LocalAuthenticationEmbeddedUI to the production LDFLAGS. The test stub is unchanged — it ignores the refsText arg so headless CI still links without AppKit. ## What I couldn't test The polecat worktree runs over SSH and has no biometric accessory paired, so canEvaluatePolicy:WithBiometrics returns false here and only the fallback path exercises. The inline-biometric path needs a session with TouchID hardware to visually verify. Reviewer is on macOS 14.6.1 with Touch ID — this is the machine to test on. Refs: op-c0e, op-knn (design), PR #7 review (rejected two-window state). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pushed rev 2 — switched to LAAuthenticationView (inline biometric UI)What I tested for the sheet theorySpent an iteration verifying the sheet-presentation theory with an ad-hoc-signed test harness driving Result: SecurityAgent still renders the LAContext modal as a floating, screen-centered dialog. It is not attached as a sheet to our window, regardless of activation policy or key-window state. The two-window stack remains. Screenshot of that state (our NSWindow partially visible behind the centered LAContext password dialog): v6-no1p.png — I have it locally at So the guess from Opus 4.6 in the review was wrong — sheet presentation is not how 1Password gets its unified look. What I found instead: LAAuthenticationView
This is the supported API for inline biometric UI. No SwiftUI needed — pure ObjC, fits the existing cgo bridge. This rev uses itNew flow:
Build: added Fallback
What I couldn't verify locallyThe polecat worktree is SSH-attached and has no biometric accessory paired ( You're on macOS 14.6.1 with Touch ID — could you:
If the inline view doesn't look right, I have two directions ready:
|
…(op-c0e)
Responds to PR review feedback on the inline TouchID window:
- Title changed to "opcli access requested" with a
"request to N secrets" subtitle, matching 1Password's target dialog.
- Refs list grouped by "[account:]vault" with "# VaultName" headers.
Vault prefix dropped from each ref so long refs no longer truncate
their interesting parts.
- Bullets dropped.
- Each vault section has its own scroll view capped at 8 visible rows
with an always-visible (legacy) scroller so long lists make it
obvious there's more to see. Outer scroll handles many vaults.
- Cancel button wired up — invalidates the LAContext so the pending
evaluatePolicy reply fires with a cancelation error; the window
closes cleanly from the normal reply path.
- TouchID icon downsized from .regular (64×64) to .small (32×32).
- Forced dark appearance so the dialog looks consistent regardless of
system light/dark mode.
Grouping is done on the Go side: `formatBiometricRefs` parses each ref
into ([account:]vault, display) and emits a JSON payload consumed by
touchid.m via NSJSONSerialization. JSON shape:
[{"vault":"Employee","refs":["item/field", ...]}, ...]
Layout uses a flipped NSView as the outer scroll's document so
sections lay out top-down without coordinate juggling — fixes a bug
in the previous rev where the first vault's header rendered above
the visible scroll area.
Iterated visually here with a mock NSImageView in place of
LAAuthenticationView (no biometric accessory paired on the polecat's
SSH session). The real LAAuthenticationView still needs verification
on a Touch ID machine — Jeremy will confirm.
Refs: op-c0e, PR #7 review comments.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pushed rev 3 — all six of your design items + cancelIterated here with a mock Your list:
Known gap: the actual Layout fix: previous rev had a bug where the first vault's header rendered above the outer scroll view's visible area. Fixed by using a flipped Ready to verify on your Mac. |
Replaces the first-pass TouchID window with the design from claude.ai/design (tweaks: field-tail emphasis, accent hue 215, footer-caption session note, boxed chrome). Visual: 440px dark panel with "opcli · access requested" title, a monospace sub-line identifying the invoking command and tty, a boxed scrollable list of op:// refs (field segment highlighted in accent), a footer caption noting the 10-minute session, and a Cancel + "Authorize with Touch ID" + LAAuthenticationView row. Command line is derived by walking the process tree from our parent and returning the first non-shell, non-opcli ancestor basenamed (e.g. "claude deploy.sh"). TTY is "ttysNNN" from ttyname(3) with a process-tree fallback (for e.g. hyperfine). Ref order is now deterministic: vaults and refs within each vault are sorted alphabetically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AppKit requires GUI work on pthread_main_np() — the process's initial OS thread. Go's main goroutine starts there, but the scheduler is free to migrate it at any blocking point, so `[NSApp run]` inside authenticateTouchID sometimes landed on a non-main thread and SIGTRAP'd about one in four invocations. runtime.LockOSThread() in an init() pins the main goroutine to its starting thread. Verified by running `opcli run echo hi` 22 times in a row without reproducing the crash. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LAAuthenticationView, used by the inline TouchID UI, is macOS 12+. Without pinning here, the target is inherited from the shell env (11.3 under our nix dev shell), producing -Wunguarded-availability-new warnings on every build. Setting both MACOSX_DEPLOYMENT_TARGET (for the direct clang call) and CGO_CFLAGS (-mmacosx-version-min, for go build / go test) ensures the floor is applied consistently. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The triple-space gutters around · in the monospace sub-line
("$ cmd · tty") and the vault header ("Vault · N") read as
too loose — the proportional title line sets its own gap via flex
in the design, but mono spaces are wider and overshoot.
Dropped them to single spaces. Title line keeps its looser spacing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Always-on, best-effort logging on both sides of the cgo boundary, to diagnose an intermittent (~1/4) hang where the auth window stays on screen after a successful scan. The log discriminates between the candidate failure points: stuck inside [NSApp run], window dismissal lost after a clean run-loop exit, or something on the Go side between auth and forking the child. Also mark hyphenated release tags (eg v0.13.1-pre1) as GitHub pre-releases so they're hidden from /releases/latest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Debug logs from a live hang showed the failure mode: stopApp runs, isVisible=0, NSApp run returns cleanly, the child starts — but the window server still shows the auth window, now frozen forever because nothing services its events again (~1/4 of auths). After NSApp run returns, pump the run loop until CGWindowList reports no on-screen windows for this pid (capped at 500ms, logged). Live verification shows the window genuinely lags the run-loop exit: clearing it took one 10ms pass. Also invalidate the LAContext before closing the window so the remote fingerprint view detaches first, and drop the activation policy back to Prohibited so no opcli Dock icon lingers for the life of long `opcli run` children. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New keyhole-with-terminal-cursor logo (assets/logo.svg, master file; assets/logo.png rendered at 512px). The PNG is embedded in the binary and set as the Dock icon while the authorization window is up, and shown at the top of the README. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Summary
Implements Option 1 from the op-knn design — a custom
NSPanelfloats above the terminal listing everyop://reference involved in the upcoming TouchID authentication, then the systemLAContextdialog appears on top of it. Same pattern used by 1Password, pinentry-mac, and ssh-askpass.History (this bead has two prior reverts)
6e039b6, reverted in PR Revert broken polecat commits (op-rba, op-c0e, op-xev.2) #5b1a18f0): printed refs to stdout → polluted piped output likeeval $(opcli ...).389198e, reverted in PR Revert broken polecat commits (op-rba, op-c0e, op-xev.2) #5699977d): put refs in theLAContextlocalizedReasonstring → too cramped (~60-80 visible chars), plus broke CI tests.483346e) reverted both attempts and pinned Option 1 as the required approach.This PR implements Option 1 and explicitly avoids Options 4/5 (terminal output, helper app) per the bead's out-of-scope list.
Changes
touchid.m— creates anNSPanelwithNSWindowStyleMaskUtilityWindowlisting refs, registersNSAppasNSApplicationActivationPolicyAccessory(no dock icon), pumpsCFRunLoopRunInModewhile blocking on theLAContextreply. Window is closed on auth completion (success or failure).touchid_stub.m— matches the new two-arg signature; ignoresrefsText. Still linked under-tags testso headless CI builds without AppKit.keychain.go—AuthenticateBiometric(reason, refs)now takes the ref list; empty list suppresses the window entirely. Dedup preserves order.main.go— re-introducesAccountKeychains.pendingRefs;cmdRead,cmdInject,cmdRunpopulate it;aks.getconsumes it only when a new biometric auth will actually be prompted.cgo_prod.go— linksAppKit.Makefile— suppressesdeprecated-declarationsnoise for the new imports.How this avoids PR #5's revert outcome
-tags testbuilds continue to link without AppKit.go vetclean; production and test-tag builds both compile.Test plan
go vet ./...— cleanmake) — succeeds, links AppKitgo build -tags test) — succeeds without AppKitopcli read op://Vault/Item/field→ window shows 1 ref + system TouchID prompt appears on topopcli inject -i template_with_several_refs.txt→ window shows all unique refs dedupedopcli run --env-file=... -- cmd→ window lists env-var refs + arg refsOut of scope
Refs: op-c0e, op-knn (design), PR #5 (the revert), op-rba (earlier attempt), op-atk (pre-existing SIGN_IDENTITY test failure)
🤖 Generated with Claude Code