Skip to content

fix: show pending refs in custom NSWindow at TouchID prompt (op-c0e) - #7

Merged
jeremyschlatter merged 10 commits into
mainfrom
polecat/quartz-mnxkby6z
Jul 2, 2026
Merged

fix: show pending refs in custom NSWindow at TouchID prompt (op-c0e)#7
jeremyschlatter merged 10 commits into
mainfrom
polecat/quartz-mnxkby6z

Conversation

@jeremyschlatter-intern

Copy link
Copy Markdown
Collaborator

Summary

Implements Option 1 from the op-knn design — a custom NSPanel floats above the terminal listing every op:// reference involved in the upcoming TouchID authentication, then the system LAContext dialog appears on top of it. Same pattern used by 1Password, pinentry-mac, and ssh-askpass.

History (this bead has two prior reverts)

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 an NSPanel with NSWindowStyleMaskUtilityWindow listing refs, registers NSApp as NSApplicationActivationPolicyAccessory (no dock icon), pumps CFRunLoopRunInMode while blocking on the LAContext reply. Window is closed on auth completion (success or failure).
  • touchid_stub.m — matches the new two-arg signature; ignores refsText. Still linked under -tags test so headless CI builds without AppKit.
  • keychain.goAuthenticateBiometric(reason, refs) now takes the ref list; empty list suppresses the window entirely. Dedup preserves order.
  • main.go — re-introduces AccountKeychains.pendingRefs; cmdRead, cmdInject, cmdRun populate it; aks.get consumes it only when a new biometric auth will actually be prompted.
  • cgo_prod.go — links AppKit.
  • Makefile — suppresses deprecated-declarations noise for the new imports.

How this avoids PR #5's revert outcome

  • Stdout pollution — the window replaces stderr output entirely; zero stdout writes.
  • Dialog truncation — the window has its own scroll view, so an arbitrary number of refs display in full.
  • Test breakage — the stub's signature is updated to match and still ignores the new arg, so -tags test builds continue to link without AppKit. go vet clean; production and test-tag builds both compile.

Test plan

  • go vet ./... — clean
  • Production build (make) — succeeds, links AppKit
  • Test-tag build (go build -tags test) — succeeds without AppKit
  • Manual (cannot run automated — headless CI can't render NSWindow):
    • opcli read op://Vault/Item/field → window shows 1 ref + system TouchID prompt appears on top
    • opcli inject -i template_with_several_refs.txt → window shows all unique refs deduped
    • opcli run --env-file=... -- cmd → window lists env-var refs + arg refs
    • With a valid unexpired session → no window shown (auth is skipped entirely)
    • Cancelling TouchID → window closes, error is returned normally
    • TouchID failure → window closes, error is returned normally
  • E2E tests — existing pre-existing failure tracked in op-atk (SIGN_IDENTITY on machines without a Developer ID cert); not introduced by this change

Out of scope

  • Option 4 (terminal/stderr output) — explicitly ruled out by the bead
  • Option 5a (separate helper app) — deferred
  • Customizing the system TouchID dialog itself — impossible (Option 2 in design)

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

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 jeremyschlatter left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The official op CLI creates custom TouchID prompt windows that look like this:

Screenshot 2026-04-13 at 12 20 24 PM

By contrast, this PR causes us to end up with two windows on top of each other like this:

Screenshot 2026-04-13 at 12 29 49 PM

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:

  1. NSApplication initialized with NSApplicationActivationPolicyRegular (not .Accessory) — the app must be a "real" foreground app, not a background utility
  2. [NSApp activateIgnoringOtherApps:YES] to claim foreground status
  3. The custom NSWindow must be makeKeyAndOrderFront: before calling evaluatePolicy:
  4. 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

  1. Whether NSApplicationActivationPolicyRegular from a CLI-launched process actually works, or whether macOS requires an app bundle (Info.plist, proper CFBundleIdentifier) for foreground
    activation.
  2. 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>
@jeremyschlatter-intern

Copy link
Copy Markdown
Collaborator Author

Pushed rev 2 — switched to LAAuthenticationView (inline biometric UI)

What I tested for the sheet theory

Spent an iteration verifying the sheet-presentation theory with an ad-hoc-signed test harness driving touchid.m through an app bundle launched via open (needed for the GUI session — SSH process context gives LAErrorUserInteractionRequired). Setup: NSApplicationActivationPolicyRegular + [NSApp run] + makeKeyAndOrderFront: + activateIgnoringOtherApps: before evaluatePolicy:.

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 /tmp/op-c0e-review/v6-no1p.png if useful.

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

LocalAuthenticationEmbeddedUI.framework ships an ObjC class LAAuthenticationView (NSView subclass, macOS 12+ — not 14+ as the review assumed, so SDK availability isn't a concern). From the SDK header (LAAuthenticationView.h):

When evaluatePolicy or evaluateAccessControl is called on this context, the UI will be presented using this view rather than using the standard authentication alert.

This is the supported API for inline biometric UI. No SwiftUI needed — pure ObjC, fits the existing cgo bridge.

This rev uses it

New flow:

  1. NSApp set up as Regular foreground app (required for LAContext to accept the call).
  2. Inside applicationDidFinishLaunching: (only reliable way to have the process be treated as foreground by LAContext), we create an NSWindow containing: header, scrollable refs list, LAAuthenticationView at the bottom.
  3. evaluatePolicy: is called on the same LAContext instance the view is paired with. The biometric prompt renders inside our view — no separate SecurityAgent modal. Single unified card.
  4. On auth completion (success or failure), we close the window and [NSApp stop:].

Build: added -framework LocalAuthenticationEmbeddedUI to prod LDFLAGS.

Fallback

LAAuthenticationView only supports biometric-ish policies. If canEvaluatePolicy:WithBiometrics returns false (no Touch ID hardware / no Magic Keyboard with TouchID / no Watch), we fall back to classic LAContext.evaluatePolicy with device-owner auth — the old system password alert, no custom window. Not unified, but functional for the minority case.

What I couldn't verify locally

The polecat worktree is SSH-attached and has no biometric accessory paired (Biometric accessory is not paired. from canEvaluatePolicy). Only the fallback path exercises here. The inline LAAuthenticationView path needs a session with Touch ID hardware to visually confirm.

You're on macOS 14.6.1 with Touch ID — could you:

  1. Pull polecat/quartz-mnxkby6z, make sign SIGN_IDENTITY=…, and run opcli read op://Vault/Item/field from Ghostty?
  2. Capture the result.

If the inline view doesn't look right, I have two directions ready:

  • Tweak layout/dimensions of the surrounding window.
  • If for some reason LAAuthenticationView doesn't render correctly when driven by a CLI process (vs. a full Cocoa app), fall back to a custom "Authorize with Touch ID" button that triggers evaluatePolicy on click — matches 1Password's 2-click flow with a separate system modal after click, but at least the pre-click state is a single window.

…(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>
@jeremyschlatter-intern

Copy link
Copy Markdown
Collaborator Author

Pushed rev 3 — all six of your design items + cancel

Iterated here with a mock NSImageView in place of LAAuthenticationView (the polecat session has no biometric accessory paired). Final commit swaps the mock back to the real LAAuthenticationView at NSControlSize.small (32×32) — the previous default was .regular (64×64), which is what you flagged as too large.

Your list:

  1. ✅ Cancel button — invalidates the LAContext, the pending evaluatePolicy reply fires with a cancelation error and exits the run loop cleanly through the normal completion path. Esc also cancels.
  2. ✅ Bullets dropped.
  3. ✅ Refs grouped by [account:]vault with # VaultName headers. Vault prefix dropped from each ref. Grouping happens Go-side in formatBiometricRefs — emits JSON, consumed by touchid.m via NSJSONSerialization.
  4. ✅ Each vault section is its own inner NSScrollView capped at 8 visible rows with NSScrollerStyleLegacy + autohidesScrollers:NO so the scroll track stays visible even when not scrolling (makes it obvious there's more). Outer scroll handles overflow across many vaults.
  5. ✅ Title bumped to 17pt semibold ("opcli access requested"), subtitle 12pt secondary ("request to read N secrets"). "Touch ID" in the authorize label rendered semibold via NSAttributedString.
  6. ✅ Forced dark appearance (NSAppearanceNameDarkAqua) so the card looks like the 1P target regardless of system light/dark mode.

Known gap: the actual LAAuthenticationView rendering (stroke/animation/hover of the fingerprint glyph) is only visible with real biometric hardware, so you'll need to eyeball it. If the icon is still too large at .small, I can drop to .mini (16×16). The four NSControlSize options give 16 / 32 / 64 / 128.

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 NSView as the scroll's document view (top-down coords) — the first section is now always visible.

Ready to verify on your Mac.

jeremyschlatter and others added 4 commits April 17, 2026 15:28
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>
@jeremyschlatter

Copy link
Copy Markdown
Owner
Screenshot 2026-04-17 at 3 44 26 PM

Screenshot of latest version.

jeremyschlatter and others added 3 commits June 11, 2026 12:45
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>
@jeremyschlatter
jeremyschlatter dismissed their stale review July 2, 2026 19:38

Merging as requested

@jeremyschlatter
jeremyschlatter merged commit 586fe52 into main Jul 2, 2026
3 checks passed
@jeremyschlatter
jeremyschlatter deleted the polecat/quartz-mnxkby6z branch July 2, 2026 19:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants