Skip to content

[client] Wire the installer to the Swift agent, delete Hammerspoon - #16

Merged
drycode merged 4 commits into
mainfrom
dy/wire-swift-client
Aug 3, 2026
Merged

[client] Wire the installer to the Swift agent, delete Hammerspoon#16
drycode merged 4 commits into
mainfrom
dy/wire-swift-client

Conversation

@drycode

@drycode drycode commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Closes the gap left by merging #12 and #15.

main currently has the real client with no way to install it: #12 was additive (+3030/-0, entirely under swift/), and #13/#14 — which carried all the installer work — were closed as superseded because they were built on the client implementation #12 replaced. So install-client.sh still installs the hammerspoon cask, client/init.lua is 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.swift goes too — the Swift Recorder inlines capture, so it had no callers left.

The installer

Builds swift/ into ~/Applications/Hark.app and registers hark agent as a LaunchAgent. Keeps the two things the Hammerspoon installer did that the agent had no equivalent for:

  • SSH key fetch for a two-machine setup — ./install-client.sh <ssh-host>, or it prompts. Without it a laptop install regresses to copying a file by hand.
  • 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.

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 outside

This is the part worth reviewing. Both permissions were got wrong the other way during bring-up, and both produced confident false PASSes:

  • 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 every rebuild is a new identity while the old row still reads auth_value=2 and System Settings still draws a switched-ON toggle. This check printed PASS while the agent was alerting on screen that it could not paste.

So every permission check now reads what the agent itself published. Also added:

  • the hotkey binding — which was a hardcoded "registered" literal, and is how a dead CGEventTap looked healthy from outside
  • a freshness check — the heartbeat rewrites every 30 s, so a stale file means the agent died without saying so and every field is a claim about a dead pid

Kept deliberately

Migration: ~/.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:

brew uninstall --cask hammerspoon
rm ~/.hammerspoon/init.lua

Revoking 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: tccutil resolves the bundle id through Launch Services, so revoke before you uninstall; afterwards it fails -10814 and 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.

drycode added 4 commits August 3, 2026 17:09
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
@drycode
drycode merged commit fa500d8 into main Aug 3, 2026
4 checks passed
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.

1 participant