[agent] Add a native Swift client alongside Hammerspoon - #13
Conversation
Accessibility is currently granted to Hammerspoon — a general-purpose scriptable Lua runtime — and its config is a symlink into this repo, so a git pull changes what that grant covers without re-prompting. A single-purpose bundle asks for the same permission with far less behind it. Ships as an opt-in preview that changes nothing about the existing path: client/init.lua and install-client.sh are untouched, so a running install keeps working and rolling back is uninstall plus relaunching Hammerspoon. The Lua client is deleted in a follow-up, once both machines are proven on the agent. Three decisions the constraints made rather than preference: An .app bundle, not a bare binary. TCC keys a bundle's grants to its identifier and signature, so they survive a rebuild; a bare binary is keyed by path and cdhash and loses the grant every recompile, while macOS leaves the stale row visible with its toggle still ON. Microphone additionally needs NSMicrophoneUsageDescription, which needs a plist. Carbon RegisterEventHotKey, not an NSEvent global monitor. A global monitor cannot consume the event, so Ctrl+Alt+Space would also reach the focused app. RegisterEventHotKey consumes it, delivers pressed and released separately, and needs no permission of its own — it is what hs.hotkey used underneath. Accessibility is still required, for the synthesized Cmd+V. The mic-status file contract survives. TCC attributes a request to the responsible process, so a probe run from a shell tests the terminal's grant and produces a confidently wrong PASS. Only the agent can answer for the agent, so it writes the answer where the shell can read it. Config moves to ~/.config/hark/client.json, beside the server's own config. JSON rather than TOML so this stays a single-file swiftc build: Swift has no stdlib TOML parser and neither a SwiftPM manifest nor a hand-rolled one earns its keep for two fields. Solves: GitHub issue #2, part 1 of 2 Tests: 29 pytest cases; shellcheck; new macOS CI job builds and verifies the signed bundle
The agent never appeared in System Settings -> Privacy & Security ->
Microphone, and rec exited 3 instantly on every launch.
Under the hardened runtime, which build-agent.sh enables and which
notarization will require, microphone access needs
com.apple.security.device.audio-input. Without it TCC does not deny the
request after asking - it refuses to ASK:
Prompting policy for hardened runtime; service: kTCCServiceMicrophone
requires entitlement com.apple.security.device.audio-input but it is
missing for responsible={com.drycodeworks.hark-agent}
Policy disallows prompt for ...; access to kTCCServiceMicrophone denied
Both ends of that are silent. The pane lists only apps that have
successfully requested, so a refused prompt means no row ever appears
and there is nothing to switch on. The code sees authorizationStatus ==
.denied immediately, which is exactly what a real user refusal looks
like. Only the unified log names the entitlement.
Dropping --options runtime would also have fixed it, and would have
broken notarization later instead. The entitlement is the real fix, so
the hardened runtime stays on and a test pins it there. Both binaries
are signed with it: the app is the responsible process tccd checks, rec
is the process that opens the device. The build now asserts the
entitlement survived into the signature, because a signature that merely
verifies proves nothing - the bundle signs, launches and runs perfectly
without it and only fails at the microphone, where it reads as a
permission problem rather than a build problem.
Two diagnostics the same run exposed:
The installer slept a fixed 3s before running --doctor, which raced the
consent dialog and reported a spurious microphone FAIL while the prompt
was still on screen. It now polls for the probe's own status file, since
that cannot land until the user has answered.
rec's permission message named Hammerspoon. rec is spawned by both
clients and TCC attributes the grant to whichever is responsible, so the
row to switch on reads "Hammerspoon" under the Lua client and "hark"
under the agent. Naming one sent users looking for a row that was never
going to be there.
Solves: hark issue #2 — first real run on the Studio
Tests: 6 cases pinning the entitlement, both signing calls, the build-time assertion, the hardened runtime and rec's app-agnostic help
The doctor reported "PASS Accessibility is granted" while the agent was alerting on screen that it could not paste, and System Settings drew a switched-ON toggle for a binary that nothing trusted. Three sources agreeing with each other and disagreeing with reality. The row in TCC.db outlives the grant it describes. An ad-hoc signature's designated requirement is a bare cdhash: designated => cdhash H"6836bec46e8c7d394cf1ba94421ff18a31674867" so every rebuild is a new identity. The old row survives with auth_value=2, the pane keeps drawing its toggle, and the running binary is trusted by nobody. Reading that row can only ever report what was true for some earlier build. This is the same rule the microphone probe already followed, arrived at the expensive way: only the process can answer for the process. The agent now records its own AXIsProcessTrusted() result to ~/.config/hark/agent-accessibility-status and the doctor reads that. A missing report is a SKIP with guidance, never a PASS - an agent predating this check must not be reported as granted. Trust is re-checked at paste time rather than only at launch, since a grant can be revoked, or silently invalidated by a rebuild, while the process keeps running. Paste is the moment it matters. Also holds Cmd+V for 200ms between key-down and key-up, matching hs.eventtap.keyStroke's `local keyDelay = 200000`. A zero-duration keystroke is silently dropped by some apps. That was not what broke this - the process being untrusted was - but back-to-back posting was a real difference from the implementation being replaced. Verified end to end on the Studio: four consecutive dictations recorded, transcribed and pasted at the cursor. Solves: hark issue #2 — the false PASS found during first-run bring-up Tests: 7 cases covering the doctor's three states, that the mechanism cannot come back, and that trust is rechecked at paste time
Verified end to end on the StudioFour consecutive dictations recorded, transcribed and pasted at the cursor. Taking this out of draft. Bring-up found two real defects, both now fixed here, and both of the same family: a permission failure that reports success. 1. The hardened runtime needs an entitlement to even askThe agent never appeared in System Settings → Privacy & Security → Microphone, and Both ends are silent. That pane lists only apps that have successfully requested, so a refused prompt leaves no row to switch on, and the code sees Dropping 2. The doctor reported a grant that had stopped applyingWorse than the first, because it sent the search in the wrong direction. An ad-hoc signature's designated requirement is a bare cdhash: so every rebuild is a new identity. The old TCC row survives with The fix is the rule the microphone probe already followed, reached the expensive way: only the process can answer for the process. The agent records its own Trust is also re-checked at paste time rather than only at launch, and a failure now says the transcript is on the clipboard and to press Cmd+V — the diagnostic that would have made this a one-round-trip problem instead of three. Also
This is what DRY-723 is forThe re-grant-per-rebuild cost is now measured rather than predicted. Every rebuild invalidates both grants, and the stale-ON-toggle is exactly the confusing state that ticket described. A Developer ID signature replaces the cdhash requirement with the certificate, so grants survive rebuilds outright. 124 tests, shellcheck, and the macOS bundle build all green. |
Every rebuild changes the ad-hoc cdhash, which is the whole of the bundle's designated requirement, so the Accessibility grant stops applying. macOS does not tidy up after it: the old row survives with auth_value=2 and System Settings keeps drawing a switched-ON toggle for a binary nothing trusts. Observed twice in one afternoon (6836bec4... -> be5a5c92...), and it is genuinely misleading - you go to grant the permission, find it already granted, and go looking somewhere else for the fault. The installer now records the installed cdhash and, when it changes, says so plainly and clears the entry with tccutil so the agent's prompt lands on a cleared slot rather than a stale one. Toggling it off and on by hand does the same thing; this removes the detour. Only Accessibility is reset. It is the grant observed to break, and the microphone path already tells the truth on its own - the agent's probe runs rec and reports what actually happened, so a stale microphone row cannot produce the false PASS a stale Accessibility row did. Resetting it anyway would cost a consent dialog for nothing. A Developer ID signature makes this dead code, since the requirement becomes the certificate rather than the hash. Until then this is the difference between one clean prompt and a confusing detour on every rebuild. Solves: hark issue #2 — the re-grant dance, hit on the first real update Tests: 5 cases covering first install, unchanged reinstall, a changed binary, and that the microphone grant is left alone
|
Superseded by #12. See #14 for the full note — same reason, and the commits here are referenced from it. For the record: I built this without checking open PRs, only open issues, and read #2's "Open questions" as live when #10/#11 had already settled them. That is on me. The hardware findings it produced are ported onto #12 on Branch kept, not deleted. |
Part 1 of 2 for #2. Adds the native agent alongside the Hammerspoon client
and changes nothing about the existing path —
client/init.luaandinstall-client.share untouched, so a running install keeps working. Part 2deletes the Lua client once both my machines are proven on the agent.
Draft because it has not been run yet. The bundle builds, signs, and
verifies in CI, and the scripted surface is under test, but no one has held the
hotkey and watched a transcript land. That needs a machine with the TCC prompts
answered. Marking ready once it is exercised end to end.
Three decisions the constraints made, not preference
An
.appbundle, not a bare binary. TCC keys a bundle's grants to itsidentifier and signature, so they survive a rebuild. A bare binary is keyed by
path and cdhash and loses the grant on every recompile — and macOS leaves the
stale row in the privacy pane with its toggle still ON, so there is no prompt
and no error, the hotkey just stops. Bundle stability is the one thing
Hammerspoon was still buying us. Microphone additionally requires
NSMicrophoneUsageDescription, which a bare binary has nowhere to put; withoutit the process is killed outright the moment it opens the device.
Carbon
RegisterEventHotKey, notNSEvent.addGlobalMonitorForEvents. Aglobal monitor cannot consume the event it observes, so
Ctrl+Alt+Spacewouldreach the focused app as well as us.
RegisterEventHotKeyconsumes it, deliverspressed and released as distinct events, and needs no permission of its own — it
is what
hs.hotkeyused underneath, so it is already proven against this exactkey and hold pattern. Accessibility is still required, but for the synthesized
Cmd+Vrather than the hotkey. One prompt, not two.The mic-status file contract survives, at
~/.config/hark/agent-mic-status.The reason it existed does not go away with Hammerspoon: TCC attributes a
request to the responsible process, so a probe run from a shell tests the
terminal's grant and produces a confidently wrong PASS. Only the agent can
answer for the agent.
Answering the rest of #2's open questions
codesign -s -), which gives a cdhash identity —grants survive until the binary changes, so one re-grant per rebuild rather
than one per launch.
build-agent.shtakesHARK_SIGN_IDENTITYfor aDeveloper ID build. The certificate is gated on Apple Developer paperwork, not
on this PR.
~/.config/hark/client.json, beside the server's own config. JSONrather than TOML so this stays a single-file
swiftcbuild — Swift has nostdlib TOML parser, and neither a SwiftPM manifest nor a hand-rolled one earns
its keep for two fields.
install-agent.shreads~/.hammerspoon/hark-config.luaandnever modifies it, so rollback is
--uninstallplus relaunching Hammerspoon.They cannot both hold the hotkey
Ctrl+Alt+Spaceis a system-wide registration and exactly one process gets it.Whichever starts first wins; the other reports it could not register. Coexist
means "both installed, one running".
install-agent.shquits Hammerspoon unlessgiven
--keep-hammerspoon, and--doctorfails the check if Hammerspoon is up.What is kept from
init.luaEverything #2 listed as non-optional: press/release with the double-key-down
guard, the indicator cleared from the process-exit callback rather than on
key-up,
X-Hark-Key+audio/wav, the error mapping for 401/415/400/503 andconnection failure each naming its likely cause, the clipboard deliberately not
restored,
Cmd+Vand never Return, the startup mic probe and Accessibilitycheck, and rec's exit code and stderr appended to a persisted log
(
~/Library/Logs/hark-agent.log).Two things improved rather than ported: the agent can actually trigger the
Accessibility prompt where the Lua client could only nag, and
recships insidethe bundle so a nested binary is covered by the bundle's signature.
Tests
29 new cases, aimed at the failure modes that are silent:
Info.plisthas a non-placeholder microphone usage string,LSUIElement, andthe identifier TCC will key grants to
--doctorlooks the client up inTCC.dbby that stringwrite_client_configemits JSON the Swift side decodes, at mode 600, withquotes and backslashes escaped
client.jsonbeats the legacy Lua file beatsthe local server key; no key anywhere fails loudly rather than writing an empty one
hark-config.lua--doctornever PASSes adeniedorerrormic status, or a missing oneagent_loadedsurvives thelaunchctl | grep -q+pipefailSIGPIPE trap thatbit
install-server.shin 156bb69kVK_Return, never restores the clipboard,and uses a WAV path the Hammerspoon client cannot collide with
CI gains a macOS job that builds the bundle and asserts both it and the nested
recverify undercodesign --strict.