[client] Wire the installer to the Swift agent, delete Hammerspoon - #16
Merged
Conversation
main was left in an intermediate state: #12 landed swift/ additively and touched nothing else, and the PRs carrying the installer work were closed as superseded because they were built on the client implementation #12 replaced. So the repo had the real client with no way to install it, while install-client.sh still installed the hammerspoon cask and the README still documented it. Anyone following the README got Hammerspoon. Removed: client/init.lua, client/hark-config.example.lua, tests/test_client_record.lua, the Lua toolchain and suite from CI, and the cask from the install path. client/rec.swift goes too — the Swift Recorder inlines capture, so it had no callers left. install-client.sh now builds swift/ into ~/Applications/Hark.app and registers `hark agent` as a LaunchAgent. It keeps what the Hammerspoon installer did that the agent had no equivalent for: fetching the shared secret over SSH for a two-machine setup, and the /health check — everything local can be healthy while the server is unreachable, and from the user's chair that is indistinguishable from a microphone fault. THE DOCTOR NOW READS THE AGENT'S OWN status.json, and nothing is measured from outside. Both permissions were got wrong the other way during bring-up: - a microphone probe run from the installer tests the TERMINAL's grant, because TCC attributes to the responsible process - querying TCC.db for Accessibility reports what was true for an EARLIER build: an ad-hoc designated requirement is a bare cdhash, so a rebuild is a new identity while the old row still reads granted and System Settings still draws a switched-ON toggle Both produced confident false PASSes. status.json also carries the hotkey binding, which used to be a hardcoded literal, and a freshness check — the heartbeat rewrites every 30s, so a stale file means the agent died without saying so and every field is a claim about a dead pid. The installer will not write a config the agent refuses. The agent's transport policy is plain HTTP to loopback always, to a numeric IP only with an explicit allowPlaintext, to a hostname never; writing an invalid config would just move the failure to first launch, where it reads as a broken agent rather than a wrong address. Migration is kept: ~/.hammerspoon/hark-config.lua is still read into client.json when the latter is absent, since for anyone mid-upgrade that file is the only place their key lives. Hammerspoon is quit but NOT uninstalled and its grants are NOT revoked — the README says to do both by hand, because revoking them is the actual point of #2 and that is not something to do silently to someone's machine. Solves: GitHub issue #2 — the wiring #13/#14 carried before being closed Tests: 119 pass; new coverage for the transport policy, the status.json doctor states, and staleness. Doctor verified against a live agent: 10/10.
The warning said audio and transcripts "will cross the network unencrypted", which is wrong on the setup this is built for: Tailscale carries the traffic over WireGuard, so the bytes are already encrypted between the two machines. What allowPlaintext actually means is no TLS INSIDE an encrypted tunnel — the real cost is that the server is not authenticated to the client, not that anything is on the wire in clear. Overstating it is not harmless: it reads as "hark is insecure by default" for a setup that is not, and it obscures the actual gap. Also points at the fix rather than just naming the risk. A tailnet with HTTPS enabled can issue a real certificate for its MagicDNS name, and a hostname over https needs no opt-in at all. Solves: reported after the wording misled during the laptop install Tests: the numeric-IP case now asserts the warning does NOT claim "unencrypted"
The two-machine setup could not connect at all: Cannot start load of Task ... since it does not conform to ATS policy finished with error [-1022] ... requires the use of a secure connection NSAllowsLocalNetworking covers .local names and link-local addresses. A Tailscale peer is neither - the tailnet uses CGNAT space, 100.64.0.0/10. The same build worked on the machine running the server, because there that address belongs to the local host and CFNetwork treats it as local. So this was invisible to a single-machine install, invisible to CI, and invisible to every test: it needs two machines and a real tunnel to appear at all. curl reached the server fine throughout, which is what made it look like an agent bug rather than a policy one. ATS's supported exceptions are static domain lists, and the server address here is user configuration - there is nothing to enumerate at build time. So the app declares arbitrary loads and enforces its own policy instead, which is stricter than ATS where it matters: ClientConfig.validateTransport allows plain HTTP to loopback always, to a numeric IP only with an opt-in recorded in the config, and to a HOSTNAME never - where ATS would happily allow a hostname it considered local. The comment says when the exception can be removed rather than leaving it to be discovered: once the server speaks TLS. A tailnet with HTTPS enabled issues a real certificate for its MagicDNS name, and an https hostname needs no exception at all. Solves: hark #2 — laptop could not reach the Studio Tests: 122 pass; the ATS key, its recorded rationale, and the app-side policy that justifies it are all pinned
The previous commit added NSAllowsArbitraryLoads and the two-machine setup still failed with -1022, with the installed bundle demonstrably carrying the key. The two keys are not additive. Apple: if NSAllowsLocalNetworking is present, the system IGNORES NSAllowsArbitraryLoads on macOS 10.12+ — the more specific key wins and the general one is silently discarded. So the bundle advertised an exception that was inert, and the plist looked correct while every request failed. Keeping only NSAllowsArbitraryLoads. The comment says not to add the other back, since it reads like a harmless narrowing and is anything but. NSLocalNetworkUsageDescription stays — that is the macOS 15+ local network privacy prompt, unrelated to ATS. Solves: hark #2 — laptop still could not reach the Studio after the first ATS fix Tests: 123 pass; a test now pins that the suppressing key is absent, not merely that the exception is present
This was referenced Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the gap left by merging #12 and #15.
maincurrently has the real client with no way to install it: #12 was additive (+3030/-0, entirely underswift/), and #13/#14 — which carried all the installer work — were closed as superseded because they were built on the client implementation #12 replaced. Soinstall-client.shstill installs thehammerspooncask,client/init.luais still present, and anyone following the README today gets Hammerspoon.Removed
client/init.lua,client/hark-config.example.lua,tests/test_client_record.lua, the Lua toolchain and suite from CI, and the cask from the install path.client/rec.swiftgoes too — the SwiftRecorderinlines capture, so it had no callers left.The installer
Builds
swift/into~/Applications/Hark.appand registershark agentas a LaunchAgent. Keeps the two things the Hammerspoon installer did that the agent had no equivalent for:./install-client.sh <ssh-host>, or it prompts. Without it a laptop install regresses to copying a file by hand./healthcheck. Everything local can be healthy while the server is unreachable, and from the user's chair that is indistinguishable from a microphone fault.It also refuses to write a config the agent will reject. The agent's transport policy is plain HTTP to loopback always, to a numeric IP only with an explicit
allowPlaintext, to a hostname never. Writing an invalid config just moves the failure to first launch, where it reads as a broken agent rather than a wrong address.The doctor reads
status.json, and measures nothing from outsideThis is the part worth reviewing. Both permissions were got wrong the other way during bring-up, and both produced confident false PASSes:
TCC.dbfor Accessibility reports what was true for an earlier build. An ad-hoc designated requirement is a barecdhash, so every rebuild is a new identity while the old row still readsauth_value=2and System Settings still draws a switched-ON toggle. This check printedPASSwhile the agent was alerting on screen that it could not paste.So every permission check now reads what the agent itself published. Also added:
"registered"literal, and is how a deadCGEventTaplooked healthy from outsideKept deliberately
Migration:
~/.hammerspoon/hark-config.luais still read intoclient.jsonwhen the latter is absent, since for anyone mid-upgrade that file is the only place their key lives.Hammerspoon is quit but not uninstalled, and its grants are not revoked. The README says to do both by hand:
brew uninstall --cask hammerspoon rm ~/.hammerspoon/init.luaRevoking the Accessibility and Microphone grants is the actual point of #2 — and doing that silently to someone's machine felt wrong. (Note for anyone following:
tccutilresolves the bundle id through Launch Services, so revoke before you uninstall; afterwards it fails-10814and the grants stay live.)Verification
119 pytest, shellcheck clean, and the doctor run against a live agent: 10/10 PASS, including microphone, Accessibility, hotkey binding, status freshness and
/health.