diff --git a/docs/superpowers/plans/2026-08-01-native-client.md b/docs/superpowers/plans/2026-08-01-native-client.md new file mode 100644 index 0000000..70d5588 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-native-client.md @@ -0,0 +1,934 @@ +# Native Swift Client Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Hammerspoon Lua client with a signed, notarised menu-bar Swift agent that captures in-process, verifies its own transport, and sanitises what it pastes. + +**Architecture:** One SPM package built and signed in CI, shipped as a notarised `Hark.app` release artifact. `install-client.sh` downloads and verifies it instead of compiling. Capture moves in-process (no `rec` child, no `/tmp/hark.wav`). The client independently sanitises and bounds every server response. + +**Tech Stack:** Swift 5.9+ / SPM, AVFoundation, Carbon or CGEventTap (decided by Task 1), Foundation `URLSession`, bash, Python 3.13 / FastAPI (server side). + +**Spec:** `docs/superpowers/specs/2026-07-31-native-client-design.md` (revision 6). Read it before Task 1. Where this plan and the spec disagree, the spec wins — report the conflict rather than guessing. + +## Global Constraints + +Every task's requirements implicitly include these. Values are copied verbatim from the spec. + +- **macOS floor:** 13 (set by `SMAppService.mainApp`). +- **Bundle ID:** `com.drycodeworks.hark-agent` — a new identifier, not an existing launchd label. +- **Install location:** `~/Applications/Hark.app`. No `sudo` anywhere in the install path. +- **Signing:** Developer ID + hardened runtime + `com.apple.security.device.audio-input` entitlement. Notarised and stapled in CI. +- **Wire format:** 16 kHz, mono, 16-bit signed PCM, little-endian, single `data` chunk. +- **Response bounds:** 1 MiB body (enforced *before* JSON decode), 8 KiB sanitised transcript, 2 KiB sanitised error `detail`. **Reject, never truncate.** +- **Timings:** 5 s starting deadline, 120 s capture cap, 30 s request timeout, 30 s status heartbeat, 90 s `--doctor` staleness limit, 90 s clipboard self-clear. +- **Transport:** HTTPS for every non-loopback hostname. Plain HTTP only for numeric loopback, or for a numeric IP literal listed in `insecure_transport_hosts`. +- **Never** synthesise Return after ⌘V. **Never** restore the previous clipboard. +- **Log lengths, never transcript content.** This applies to the agent, the server, and every test fixture. +- The client must not require the Xcode command line tools. `xcrun` and `stapler` are CI-only. + +## Task Dependency Graph + +``` +Task 1 (spike) ──gates──► Tasks 8, 9 [hotkey mechanism] +Tasks 2-3 (server) ── independent, start immediately, separately shippable +Task 4 (package skeleton) ──► Tasks 5, 6, 7 ──► Tasks 8, 9 ──► Task 10 +Task 11 (CI/release) ──► Tasks 12-14 (installer) ──► Task 15 (docs) +``` + +Tasks 2 and 3 touch only `src/hark/` and `tests/`. Tasks 5, 6 and 7 touch disjoint Swift files and can run in parallel once Task 4 lands. + +--- + +### Task 1: Phase 0 hotkey spike + +**This task produces a decision, not shipped code.** Nothing downstream may start until its verdict is recorded in the spec. + +**Files:** +- Create: `spike/hotkey/carbon.swift`, `spike/hotkey/eventtap.swift`, `spike/hotkey/README.md` +- Modify: `docs/superpowers/specs/2026-07-31-native-client-design.md` (record the verdict) + +**Interfaces:** +- Consumes: nothing. +- Produces: a recorded decision — `carbon` or `eventtap` — that Tasks 8 and 9 read from the spec. + +The spike answers two questions. Do not answer them by reasoning; measure. + +1. Does a keyboard `CGEventTap` require Input Monitoring (`kTCCServiceListenEvent`) *in addition to* Accessibility? `README.md:266-268` asserts from this project's own experience that an `hs.eventtap` watching `flagsChanged` does. If a keyDown/keyUp tap also does, Carbon needs one grant and the tap needs two. +2. Is `kEventHotKeyReleased` reliable enough for hold-to-talk? + +- [ ] **Step 1: Build both spikes as signed bundles** + +Both must be tested as a **signed, notarised bundle**, not a bare binary — TCC binds to the designated requirement, and a bare binary's behaviour does not generalise. + +- [ ] **Step 2: Run the measurement protocol** + +For each mechanism, on macOS 13 and on the newest available: + +| Check | Pass condition | +|---|---| +| 200 press/release cycles | **Zero** missed releases. A missed release strands the agent in `recording`, so the bar is zero, not low | +| Modifier roll | Press ⌃⌥Space, add ⇧ mid-hold, release. Release still delivered | +| Sustained CPU load | Zero missed releases under load | +| Display sleep/wake mid-hold | State recoverable, no stranded capture | +| Grant state | Record which TCC panes the app appears in after first run: Accessibility only, or Accessibility + Input Monitoring | +| Collision | Register while a second app holds the chord, with and without `kEventHotKeyExclusive`. Record whether registration reports it | + +- [ ] **Step 3: Record the verdict in the spec** + +Replace the spec's "Hotkey | **Undecided.**" decision row and the Phase 0 section with the outcome and the measurements behind it. If Carbon wins, state that `kEventHotKeyExclusive` is mandatory. + +- [ ] **Step 4: Commit** + +```bash +git add spike/hotkey docs/superpowers/specs/2026-07-31-native-client-design.md +git commit -m "[hark] Decide the hotkey mechanism from measurement" +``` + +--- + +### Task 2: Branch the server's 400 detail by cause + +Independent of all Swift work. Ship it whenever. + +**Files:** +- Modify: `src/hark/audio.py`, `src/hark/app.py:100-108` +- Test: `tests/test_app.py:98-115`, `tests/test_audio.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: `InvalidAudioError.kind: str`, one of `"empty"`, `"unreadable"`, `"format"`. `DictateClient` (Task 7) relies on 400 meaning *client audio format bug*, not microphone permission. + +`app.py:103-107` currently returns one detail for every `InvalidAudioError` — "Check that the client has microphone permission and is sending 16 kHz mono 16-bit PCM WAV" — and `tests/test_app.py:107` asserts that wording. Right for an empty body; wrong for a malformed header, which the native client can produce because it hand-builds one. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_app.py +def test_empty_body_still_names_the_microphone(stub_transcribe): + """A zero-byte WAV is still most likely a mis-permissioned mic.""" + response = TestClient(app).post("/dictate", content=b"", headers=HEADERS) + assert response.status_code == 400 + assert "microphone" in response.json()["detail"].lower() + + +def test_malformed_wav_does_not_blame_the_microphone(stub_transcribe): + """The native client hand-builds its header. A header bug must not send + the user to Privacy & Security -> Microphone.""" + response = TestClient(app).post( + "/dictate", content=b"not a wav at all", headers=HEADERS + ) + assert response.status_code == 400 + detail = response.json()["detail"].lower() + assert "microphone" not in detail and "mic " not in detail + assert "wav" in detail or "format" in detail +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `uv run pytest tests/test_app.py -k "microphone or malformed" -v` +Expected: `test_malformed_wav_does_not_blame_the_microphone` FAILS — the current detail contains "microphone" for every cause. + +- [ ] **Step 3: Give `InvalidAudioError` a kind** + +```python +# src/hark/audio.py +class InvalidAudioError(Exception): + """The request body is not a WAV we can measure. + + `kind` lets the HTTP layer choose advice that matches the cause. Every + cause used to produce the same "check your microphone" detail, which is + right for an empty body and actively misleading for a malformed header. + """ + + def __init__(self, message: str, kind: str) -> None: + super().__init__(message) + self.kind = kind +``` + +Then tag each raise site — `"empty"` at `audio.py:38`, `"format"` at `:46`, `"unreadable"` at `:53`: + +```python + raise InvalidAudioError( + "empty audio body - the microphone produced no samples", "empty" + ) +... + raise InvalidAudioError( + f"expected 16-bit PCM samples, got {width * 8}-bit", "format" + ) +... + raise InvalidAudioError(f"not a readable WAV: {exc}", "unreadable") from exc +``` + +- [ ] **Step 4: Branch the advice in `app.py`** + +```python + except InvalidAudioError as exc: + logger.warning("rejected audio: %s", exc) + if exc.kind == "empty": + advice = ( + "Check that the client has microphone permission and is " + "sending 16 kHz mono 16-bit PCM WAV." + ) + else: + advice = ( + "The client sent audio this server cannot read. Expected " + "16 kHz mono 16-bit PCM WAV. This is a client bug, not a " + "microphone permission problem." + ) + raise HTTPException(status_code=400, detail=f"{exc}. {advice}") from exc +``` + +- [ ] **Step 5: Run the full suite** + +Run: `uv run --locked pytest -q` +Expected: PASS. If `test_dictate_rejects_an_empty_body_naming_the_real_cause` still exists and duplicates the new empty-body test, delete the older one rather than keeping both. + +- [ ] **Step 6: Commit** + +```bash +git add src/hark/audio.py src/hark/app.py tests/test_app.py +git commit -m "[hark] Stop blaming the microphone for malformed audio" +``` + +--- + +### Task 3: Enforce the full wire format server-side + +**Files:** +- Modify: `src/hark/audio.py:42-50` +- Test: `tests/test_audio.py` + +**Interfaces:** +- Consumes: `InvalidAudioError(message, kind)` from Task 2. +- Produces: server rejection of any WAV that is not 16 kHz mono 16-bit. + +`audio.py:22` documents the wire format in a comment while `:45` enforces only sample width. A stereo or 44.1 kHz WAV is accepted today, and the RMS is then silently mis-scaled against a threshold calibrated for mono 16 kHz. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_audio.py +import io, wave, pytest +from hark.audio import rms, InvalidAudioError + + +def _wav(channels: int, rate: int, width: int = 2, frames: int = 1600) -> bytes: + buf = io.BytesIO() + with wave.open(buf, "wb") as w: + w.setnchannels(channels) + w.setsampwidth(width) + w.setframerate(rate) + w.writeframes(b"\x00\x00" * frames * channels) + return buf.getvalue() + + +def test_rejects_stereo(): + with pytest.raises(InvalidAudioError, match="mono"): + rms(_wav(channels=2, rate=16000)) + + +def test_rejects_wrong_sample_rate(): + with pytest.raises(InvalidAudioError, match="16000|16 kHz"): + rms(_wav(channels=1, rate=44100)) + + +def test_accepts_the_documented_format(): + assert rms(_wav(channels=1, rate=16000)) == 0.0 +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `uv run pytest tests/test_audio.py -k "stereo or sample_rate" -v` +Expected: both FAIL — stereo and 44.1 kHz currently pass through. + +- [ ] **Step 3: Enforce channels and rate** + +```python +# src/hark/audio.py, inside the `with wave.open(...)` block, after the width check + channels = reader.getnchannels() + if channels != SUPPORTED_CHANNELS: + raise InvalidAudioError( + f"expected mono audio, got {channels} channels", "format" + ) + rate = reader.getframerate() + if rate != SUPPORTED_SAMPLE_RATE: + raise InvalidAudioError( + f"expected a 16000 Hz sample rate, got {rate} Hz", "format" + ) +``` + +With the constants beside `SUPPORTED_SAMPLE_WIDTH`: + +```python +SUPPORTED_CHANNELS = 1 +SUPPORTED_SAMPLE_RATE = 16000 +``` + +- [ ] **Step 4: Run the full suite** + +Run: `uv run --locked pytest -q` +Expected: PASS. `tests/fixtures/hello.wav` and `silence.wav` are already 16 kHz mono — if either fails, the fixture is wrong and must be regenerated, not the check relaxed. + +- [ ] **Step 5: Commit** + +```bash +git add src/hark/audio.py tests/test_audio.py +git commit -m "[hark] Enforce the documented wire format, not just sample width" +``` + +--- + +### Task 4: SPM package skeleton and bundle layout + +**Files:** +- Create: `client/agent/Package.swift`, `client/agent/Sources/HarkAgent/main.swift`, `client/agent/Resources/Info.plist`, `client/agent/Resources/Hark.entitlements`, `client/agent/Tests/HarkAgentTests/SmokeTests.swift` + +**Interfaces:** +- Consumes: nothing. +- Produces: a buildable package. Tasks 5-9 add files under `Sources/HarkAgent/`. + +- [ ] **Step 1: Write `Package.swift`** + +```swift +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "HarkAgent", + platforms: [.macOS(.v13)], + targets: [ + .executableTarget(name: "HarkAgent", path: "Sources/HarkAgent"), + .testTarget(name: "HarkAgentTests", dependencies: ["HarkAgent"], + path: "Tests/HarkAgentTests"), + ] +) +``` + +No external dependencies. Adding one needs a note in the spec first — dependency reduction is the point of the surrounding work. + +- [ ] **Step 2: Write `Info.plist`** + +Keys: `CFBundleIdentifier` = `com.drycodeworks.hark-agent`, `CFBundleExecutable` = `HarkAgent`, `LSUIElement` = `true`, `LSMinimumSystemVersion` = `13.0`, `NSMicrophoneUsageDescription`, `NSLocalNetworkUsageDescription`, and `NSAppTransportSecurity` containing **only** `NSAllowsLocalNetworking = true`. + +Do **not** add `NSExceptionDomains`. Per-host entries cannot follow `client.json` — the plist is signed, so editing it after download invalidates the signature. Restricting insecure HTTP to IP literals is what makes a static plist sufficient. + +- [ ] **Step 3: Write `Hark.entitlements`** + +`com.apple.security.device.audio-input` = `true`. Under the hardened runtime, `NSMicrophoneUsageDescription` alone does not permit capture. + +- [ ] **Step 4: Smoke test and run** + +```swift +import XCTest +@testable import HarkAgent + +final class SmokeTests: XCTestCase { + func testPackageBuilds() { XCTAssertTrue(true) } +} +``` + +Run: `swift test --package-path client/agent` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add client/agent +git commit -m "[hark] Add the agent package skeleton" +``` + +--- + +### Task 5: Config loading and transport policy + +**Files:** +- Create: `client/agent/Sources/HarkAgent/Config.swift`, `client/agent/Tests/HarkAgentTests/ConfigTests.swift` + +**Interfaces:** +- Consumes: Task 4's package. +- Produces: + ```swift + struct Config { + let serverURL: URL + let key: String + static func load(configPath: URL, keyPath: URL) throws -> Config + } + enum ConfigError: Error, Equatable { + case missingConfig, malformedConfig(String) + case missingKey, keyContainsWhitespace + case insecureTransport(host: String) + case userinfoInURL + } + ``` + +- [ ] **Step 1: Write the failing tests** + +```swift +func testRejectsPlainHTTPToAHostname() throws { + XCTAssertThrowsError(try policy(for: "http://desktop.ts.net:8911/dictate", + allowlist: ["desktop.ts.net"])) { err in + XCTAssertEqual(err as? ConfigError, + .insecureTransport(host: "desktop.ts.net")) + } +} + +func testAllowsPlainHTTPToAnAllowlistedIPLiteral() throws { + XCTAssertNoThrow(try policy(for: "http://100.64.0.1:8911/dictate", + allowlist: ["100.64.0.1"])) +} + +func testRejectsPlainHTTPToAnUnlistedIPLiteral() throws { + XCTAssertThrowsError(try policy(for: "http://100.64.0.2:8911/dictate", + allowlist: ["100.64.0.1"])) +} + +func testAllowsLoopbackUnconditionally() throws { + XCTAssertNoThrow(try policy(for: "http://127.0.0.1:8911/dictate", allowlist: [])) +} + +func testRejectsUserinfo() throws { + XCTAssertThrowsError(try policy(for: "http://me@127.0.0.1:8911/dictate", + allowlist: [])) +} + +func testTrimsTheKeyTrailingNewline() throws { + // config.py:129 writes `key + "\n"`. Sending raw bytes 401s every request. + XCTAssertEqual(try readKey(from: "secret-value\n"), "secret-value") +} + +func testRejectsAKeyWithEmbeddedWhitespace() throws { + XCTAssertThrowsError(try readKey(from: "secret value\n")) +} +``` + +A hostname is *any* host that is not a numeric IP literal. Parse with `IPv4Address`/`IPv6Address`; do not pattern-match on dots. + +- [ ] **Step 2: Run to verify they fail** + +Run: `swift test --package-path client/agent --filter ConfigTests` +Expected: FAIL — `Config` does not exist. + +- [ ] **Step 3: Implement `Config.swift`** + +`load` reads `client.json` (`server`, `insecure_transport_hosts`), reads the key from `keyPath`, trims surrounding whitespace, rejects embedded whitespace, and applies the transport policy above. It never falls back to a default server. + +- [ ] **Step 4: Run tests** + +Run: `swift test --package-path client/agent --filter ConfigTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add client/agent/Sources/HarkAgent/Config.swift client/agent/Tests/HarkAgentTests/ConfigTests.swift +git commit -m "[hark] Load client config and enforce the transport policy" +``` + +--- + +### Task 6: In-memory recorder + +**Files:** +- Create: `client/agent/Sources/HarkAgent/Recorder.swift`, `client/agent/Tests/HarkAgentTests/WAVTests.swift` + +**Interfaces:** +- Consumes: Task 4's package. +- Produces: + ```swift + enum CaptureOutcome { case audio(Data), noFrames, allSilent, denied, deviceUnavailable } + actor Recorder { + func start() async throws + func stop() async -> CaptureOutcome + } + func buildWAV(pcm: Data, sampleRate: Int = 16000, channels: Int = 1) -> Data + ``` + +Port `client/rec.swift` and keep three behaviours it already proves correct: + +- `rec.swift:65` — switch on `AVCaptureDevice.authorizationStatus(for: .audio)`. **Never** infer permission from a frame count; a denied device returns substituted silence (issue #9, fixed in `559aafe`). +- `rec.swift:78` — `requestAccess` and pump `RunLoop.main` rather than blocking on a semaphore. The completion arrives on an unspecified queue, and a blocked main thread deadlocks. +- `rec.swift:185` — keep the zero-frame check for its real meaning: the device delivered nothing at all. + +- [ ] **Step 1: Write the failing WAV tests** + +```swift +func testHeaderIsFortyFourBytesAndRIFFWAVE() { + let wav = buildWAV(pcm: Data(count: 3200)) + XCTAssertEqual(wav.count, 44 + 3200) + XCTAssertEqual(String(decoding: wav[0..<4], as: UTF8.self), "RIFF") + XCTAssertEqual(String(decoding: wav[8..<12], as: UTF8.self), "WAVE") +} + +func testSizesAreBackPatchedFromActualLength() { + // RIFF and data sizes are unknown until release, so they are written last. + let wav = buildWAV(pcm: Data(count: 3200)) + XCTAssertEqual(le32(wav, at: 4), UInt32(36 + 3200)) // RIFF chunk size + XCTAssertEqual(le32(wav, at: 40), UInt32(3200)) // data chunk size +} + +func testDeclaresSixteenKilohertzMonoSixteenBit() { + let wav = buildWAV(pcm: Data(count: 320)) + XCTAssertEqual(le16(wav, at: 22), 1) // channels + XCTAssertEqual(le32(wav, at: 24), 16000) // sample rate + XCTAssertEqual(le16(wav, at: 34), 16) // bits per sample + XCTAssertEqual(le32(wav, at: 28), 32000) // byte rate + XCTAssertEqual(le16(wav, at: 32), 2) // block align +} + +func testServerAcceptsOurHeader() throws { + // The bytes this builder emits must satisfy the checks Task 3 added. + let wav = buildWAV(pcm: Data(count: 3200)) + try wav.write(to: URL(fileURLWithPath: "/tmp/hark-header-test.wav")) + // Assert via the Python suite in CI; see Task 11. +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `swift test --package-path client/agent --filter WAVTests` +Expected: FAIL — `buildWAV` does not exist. + +- [ ] **Step 3: Implement `buildWAV` and the recorder** + +Capture is owned by an `actor`, so the audio-thread tap and main-queue stop cannot race. `rec.swift:163` mutates `framesWritten` from the tap while `:185` reads it from `stop()` — unsynchronised today and more consequential in-process. `stop()` waits for the tap to drain and is idempotent. + +- [ ] **Step 4: Run tests** + +Run: `swift test --package-path client/agent --filter WAVTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add client/agent/Sources/HarkAgent/Recorder.swift client/agent/Tests/HarkAgentTests/WAVTests.swift +git commit -m "[hark] Capture in-process and build the WAV in memory" +``` + +--- + +### Task 7: Dictate client — bounds, sanitisation, error mapping + +**Files:** +- Create: `client/agent/Sources/HarkAgent/DictateClient.swift`, `client/agent/Sources/HarkAgent/Sanitize.swift`, `client/agent/Tests/HarkAgentTests/DictateClientTests.swift`, `client/agent/Tests/HarkAgentTests/SanitizeTests.swift` + +**Interfaces:** +- Consumes: `Config` (Task 5). +- Produces: + ```swift + enum DictateError: Error, Equatable { + case unreachable(String), badKey(String), clientBug(String) + case badAudio(String), whisperDown(String), unexpected(Int, String) + case responseTooLarge, malformedResponse + } + func sanitize(_ raw: String) -> String + struct DictateClient { func send(wav: Data) async throws -> String } + ``` + +- [ ] **Step 1: Write the failing sanitisation tests** + +`sanitize.py:23` runs on the server, and revision 1 wrongly treated that as sufficient. A compromised server — or anyone on the network path of a plaintext two-machine setup — returns JSON the server's sanitiser never touched, and "Never Return" protects nothing when the payload itself carries `\n`. + +```swift +func testCollapsesNewlinesToSpaces() { + XCTAssertEqual(sanitize("rm -rf /\nyes"), "rm -rf / yes") +} + +func testStripsC0AndC1Controls() { + XCTAssertEqual(sanitize("a\u{1B}[31mb"), "a [31mb") + XCTAssertEqual(sanitize("a\u{9B}b"), "a b") +} + +func testStripsUnicodeLineSeparators() { + XCTAssertEqual(sanitize("a\u{2028}b\u{2029}c"), "a b c") +} + +func testControlBetweenWordsSeparatesRatherThanFuses() { + // Substituting a space, not deleting, keeps two words two words. + XCTAssertEqual(sanitize("one\u{0}two"), "one two") +} + +func testMatchesThePythonImplementation() { + // Same cases as tests/test_sanitize.py. The two must not drift. + XCTAssertEqual(sanitize(" hello world "), "hello world") + XCTAssertEqual(sanitize("\r\n"), "") +} +``` + +- [ ] **Step 2: Write the failing transport tests** + +```swift +func testRejectsABodyOverOneMebibyteBeforeDecoding() async { + let stub = StubProtocol.respond(status: 200, body: Data(count: 1_048_577)) + await XCTAssertThrowsErrorAsync(try await client(stub).send(wav: tinyWAV)) { + XCTAssertEqual($0 as? DictateError, .responseTooLarge) + } +} + +func testRejectsTranscriptOverEightKibibytes() async { + let text = String(repeating: "a", count: 8193) + let stub = StubProtocol.respond(status: 200, json: ["text": text]) + await XCTAssertThrowsErrorAsync(try await client(stub).send(wav: tinyWAV)) +} + +func testFourHundredIsAClientFormatBugNotAMicrophoneProblem() async { + let stub = StubProtocol.respond(status: 400, json: ["detail": "not a readable WAV"]) + await XCTAssertThrowsErrorAsync(try await client(stub).send(wav: tinyWAV)) { + guard case .badAudio(let msg)? = $0 as? DictateError else { return XCTFail() } + XCTAssertFalse(msg.lowercased().contains("microphone")) + } +} + +func testSanitisesAndBoundsTheErrorDetail() async { + let stub = StubProtocol.respond(status: 503, json: ["detail": "down\nnow"]) + await XCTAssertThrowsErrorAsync(try await client(stub).send(wav: tinyWAV)) { + guard case .whisperDown(let msg)? = $0 as? DictateError else { return XCTFail() } + XCTAssertEqual(msg, "down now") + } +} + +func testDoesNotFollowRedirects() async { + let stub = StubProtocol.redirect(to: "https://evil.example/dictate") + await XCTAssertThrowsErrorAsync(try await client(stub).send(wav: tinyWAV)) +} +``` + +The 1 MiB bound must bind **before** JSON decoding — a post-decode cap still lets a hostile server make `URLSession` buffer an unbounded body. Use `URLSession.bytes(for:)` and abort past the limit. + +- [ ] **Step 3: Run to verify they fail** + +Run: `swift test --package-path client/agent --filter "DictateClientTests|SanitizeTests"` +Expected: FAIL — neither type exists. + +- [ ] **Step 4: Implement both** + +`DictateClient` uses an **ephemeral** `URLSession` (no on-disk credential or response cache) with a delegate that cancels redirects. Reject, never truncate: rejection is visible, truncation silently corrupts a transcript. + +- [ ] **Step 5: Run tests** + +Run: `swift test --package-path client/agent` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add client/agent/Sources/HarkAgent/DictateClient.swift client/agent/Sources/HarkAgent/Sanitize.swift client/agent/Tests/HarkAgentTests +git commit -m "[hark] Bound and sanitise every server response" +``` + +--- + +### Task 8: Hotkey binding + +**Blocked by Task 1.** Implement only the mechanism the spike selected. + +**Files:** +- Create: `client/agent/Sources/HarkAgent/Hotkey.swift`, `client/agent/Tests/HarkAgentTests/HotkeyTests.swift` + +**Interfaces:** +- Consumes: Task 1's verdict. +- Produces: + ```swift + enum HotkeyError: Error { case registrationFailed(String), chordUnavailable } + final class Hotkey { + init(onPress: @escaping () -> Void, onRelease: @escaping () -> Void) + func register() throws + func unregister() + var isRegistered: Bool { get } + } + ``` + +`register()` is called by the installer after Hammerspoon exits, **not** at launch — see Task 13's cutover ordering. + +- [ ] **Step 1: Write the failing tests** + +Only the observable contract is unit-testable; real delivery is not. + +```swift +func testStartsUnregistered() { + XCTAssertFalse(Hotkey(onPress: {}, onRelease: {}).isRegistered) +} + +func testRegistrationFailureIsReportedNotSwallowed() throws { + // The failure this replaces: hs.hotkey.bind returns success and then + // silently never fires. Registration must be observable. + let h = Hotkey(onPress: {}, onRelease: {}) + try h.register() + XCTAssertTrue(h.isRegistered) + h.unregister() + XCTAssertFalse(h.isRegistered) +} +``` + +- [ ] **Step 2: Run to verify they fail, implement, re-run** + +If Carbon was selected, pass `kEventHotKeyExclusive` — without it, registration succeeds even when another app owns the chord (`CarbonEvents.h`), and migration can claim nothing about ownership. + +- [ ] **Step 3: Commit** + +```bash +git add client/agent/Sources/HarkAgent/Hotkey.swift client/agent/Tests/HarkAgentTests/HotkeyTests.swift +git commit -m "[hark] Bind the hotkey with the mechanism the spike selected" +``` + +--- + +### Task 9: Agent controller — state machine, permissions, paste, status + +**Blocked by Tasks 5, 6, 7, 8.** + +**Files:** +- Create: `client/agent/Sources/HarkAgent/AgentController.swift`, `client/agent/Sources/HarkAgent/StatusFile.swift`, `client/agent/Tests/HarkAgentTests/StateMachineTests.swift`, `client/agent/Tests/HarkAgentTests/StatusFileTests.swift` +- Modify: `client/agent/Sources/HarkAgent/main.swift` + +**Interfaces:** +- Consumes: everything above. +- Produces: the running agent. + +- [ ] **Step 1: Write the failing state-machine tests** + +```swift +func testPressOutsideIdleIsIgnored() { /* recording -> press -> still one capture */ } + +func testStoppingBlocksASecondCaptureUntilDrainCompletes() { + // Without `stopping`, a press right after release either overlaps + // captures or drops the utterance. +} + +func testStartingAbortsAfterFiveSecondsWithNoFirstBuffer() { + // rec.swift:139 arms its ceiling only after the first buffer, so a device + // that never delivers one hangs forever. This deadline is separate from + // the 120 s capture cap. +} + +func testCaptureCapUploadsWhatWasCaptured() { /* 120 s -> stop, upload, notice */ } + +func testLateResponseFromAPreviousSequenceIsDiscarded() { + // A timed-out request that completes late must not paste into a newer + // capture's turn. +} + +func testPasteWithheldWhenFrontmostApplicationChanged() { + // Application granularity only. This does NOT catch a focus move within + // an app; do not assert that it does. +} + +func testNoCommandVWhenThePasteboardWriteFailed() { + // Otherwise ⌘V pastes whatever was on the clipboard before. +} + +func testClipboardSelfClearsAfterNinetySecondsOnlyIfChangeCountUnchanged() {} +``` + +- [ ] **Step 2: Write the failing status-file tests** + +```swift +func testProcessStartedIsTheVerbatimPsString() { + // `ps -o lstart=` prints "Fri Jul 31 14:02:11 2026" — localised, non-ISO. + // Storing what ps prints makes --doctor a trimmed string comparison + // instead of brittle date parsing. + XCTAssertFalse(StatusFile.current().processStarted.contains("T")) +} + +func testWrittenEpochIsAnInteger() {} + +func testCarriesHotkeyAndLoginItemState() { + // A heartbeat without these passes while the product does not work. +} + +func testWriteIsAtomic() { + // temp file + rename; --doctor must never read partial JSON. +} +``` + +- [ ] **Step 3: Run to verify they fail, implement, re-run** + +Permissions: `AVCaptureDevice.authorizationStatus` + `requestAccess` at launch (the Microphone pane has no "+" button and lists only apps that have already asked), and `AXIsProcessTrustedWithOptions` **with the prompt option** — `AXIsProcessTrusted` alone never prompts, leaving a fresh install no path to the grant. Re-read permissions after wake and on activation so revocation mid-session is reflected. + +`SMAppService.mainApp.register()` is **not** called here. It is the installer's last cutover step (Task 13). + +- [ ] **Step 4: Commit** + +```bash +git add client/agent/Sources/HarkAgent client/agent/Tests/HarkAgentTests +git commit -m "[hark] Add the agent state machine, permissions and status heartbeat" +``` + +--- + +### Task 10: Menu bar status item + +**Files:** +- Create: `client/agent/Sources/HarkAgent/StatusItem.swift` + +**Interfaces:** Consumes `AgentController`. Produces no API. + +Icon states: idle, recording, error. Menu: **Quit only.** + +Do not add "Reload config" or "Run diagnostics". `install-client.sh --doctor` is already the diagnostic interface, and a second one drifts from it. + +- [ ] **Step 1: Implement, verify by running the app, commit** + +```bash +git add client/agent/Sources/HarkAgent/StatusItem.swift +git commit -m "[hark] Show agent state in the menu bar" +``` + +--- + +### Task 11: CI build, sign, notarise, release + +**Files:** +- Modify: `.github/workflows/ci.yml` +- Create: `.github/workflows/release.yml`, `client/agent/scripts/make-bundle.sh` + +**Interfaces:** Produces the release artifact Task 12 downloads. + +- [ ] **Step 1: Add Swift to CI** + +`swift build` and `swift test --package-path client/agent` on `macos-latest`. Add a cross-check that `buildWAV`'s output passes the Python validators from Task 3 — one bug class, two languages, caught in CI rather than in the field. + +- [ ] **Step 2: Write the release workflow** + +Assemble `Hark.app` from the SPM product plus `Info.plist` and entitlements; sign with Developer ID **and hardened runtime**; notarise; **`xcrun stapler validate`** — the only place `stapler` is used, because it ships with Xcode and the client must not need it. Publish a universal (arm64 + x86_64) zip. + +CI must keep the **designated requirement** stable across releases and certificate rotation. TCC binds to that, not to "Team ID plus bundle ID" loosely. + +- [ ] **Step 3: Verify with a prerelease tag, then commit** + +```bash +git add .github/workflows client/agent/scripts +git commit -m "[hark] Build, sign and notarise the agent in CI" +``` + +--- + +### Task 12: Installer — download and verify + +**Files:** +- Modify: `install-client.sh` (replace the Hammerspoon install and the `swiftc` build at `:419`) +- Test: `tests/test_install_client_verify.py` + +**Interfaces:** Consumes Task 11's artifact. Produces a verified `~/Applications/Hark.app`. + +- [ ] **Step 1: Write the failing tests** + +Follow `tests/test_install_server_doctor.py`'s pattern: source the script behind its guard and stub the network. + +```python +def test_refuses_an_artifact_with_the_wrong_team_id(): ... +def test_sets_quarantine_before_assessing(): ... +def test_leaves_the_existing_install_untouched_on_failure(): ... +def test_relaunches_the_previous_bundle_on_rollback(): ... +``` + +- [ ] **Step 2: Implement** + +Stage to a temp dir. **Set `com.apple.quarantine` on the extracted app explicitly** — a `curl` + `ditto` flow need not preserve it the way a browser does, and without it `spctl` takes a weaker path, so the notarisation check would quietly not be the check it claims. Then: + +```bash +spctl --assess --type execute -vv "$STAGED_APP" +codesign --verify --deep --strict -R "$EXPECTED_REQUIREMENT" "$STAGED_APP" +``` + +Both are stock macOS binaries. **Never** call `xcrun` or `stapler` here. + +Keep the previous bundle until the new one reports a healthy heartbeat; on failure restore **and relaunch** it. + +- [ ] **Step 3: Run tests and commit** + +```bash +git add install-client.sh tests/test_install_client_verify.py +git commit -m "[hark] Install the agent from a verified release artifact" +``` + +--- + +### Task 13: Installer — staged migration off Hammerspoon + +**Files:** +- Create: `client/legacy/init-v1.lua` +- Modify: `install-client.sh`, `client/init.lua` (becomes a silent no-op stub) +- Test: `tests/test_install_client_migration.py` + +**Interfaces:** Consumes Task 12. Produces a completed cutover. + +**`client/legacy/init-v1.lua` is a verbatim copy of the last functional Lua client, committed in the SAME commit that stubs `client/init.lua`.** It cannot be copied at install time: by then the user has pulled and `client/init.lua` *is* the stub, and after a repo move the symlink is dangling and resolves to nothing. Add a test asserting the legacy asset still binds a hotkey, so a later tidy-up cannot silently empty the rollback target. + +- [ ] **Step 1: Write the failing tests** + +```python +def test_legacy_asset_still_binds_a_hotkey(): ... +def test_detects_ownership_only_from_pre_existing_evidence(): ... +def test_asks_before_acting_when_only_the_dangling_heuristic_matches(): ... +def test_does_not_touch_an_independently_managed_hammerspoon_config(): ... +def test_rollback_unregisters_the_login_item_before_relaunching(): ... +def test_writes_the_ownership_record_only_after_success(): ... +``` + +- [ ] **Step 2: Implement the cutover in this exact order** + +0. Stage `client/legacy/init-v1.lua` → `~/.config/hark/legacy-client.lua`; confirm readable. +1. Detect from **pre-existing evidence only**: a record from an *earlier* run whose recorded target matches the current symlink target; else the marker comment in the target; else a dangling symlink matching a known layout — **a heuristic, so require explicit confirmation when it is the sole evidence.** +2. Install and health-check the agent with the hotkey **disabled** and login registration **not performed**. +3. Quit Hammerspoon; wait for process exit. Chord ownership cannot be queried, so process exit is the strongest available signal. +4. Tell the agent to register the hotkey, verify, **then** `SMAppService.mainApp.register()`. +5. On failure at 4: stop the agent, ensure the login item is **not** registered, point the symlink at `~/.config/hark/legacy-client.lua`, relaunch Hammerspoon, confirm running, report. +6. On success only: write the ownership record, remove the symlink, offer `tccutil reset` for **Accessibility, Microphone and ListenEvent**. +7. If revocation is declined, say plainly the original exposure remains. + +Registration is last because a rolled-back install that still registered would start at next login and fight the restored Hammerspoon. + +- [ ] **Step 3: Run tests and commit** + +```bash +git add client/legacy/init-v1.lua client/init.lua install-client.sh tests/test_install_client_migration.py +git commit -m "[hark] Cut over from Hammerspoon with a working rollback" +``` + +--- + +### Task 14: Installer — rewrite `--doctor` + +**Files:** Modify `install-client.sh` (`:86`, `:127`, `:173`, `:196`, `:679`, `:742`). Test: `tests/test_install_client_doctor.py`. + +Replace every Hammerspoon-era check. New checks: bundle exists and verifies; agent running; heartbeat fresh (**PID alive, `ps -o lstart=` matches `process_started` after trimming, `written_epoch` within 90 s**); `hotkey` registered; `login_item` enabled; permissions authorised; server reachable; key authenticates. + +No agent running is a **FAIL**, not a skipped check. Warn on every `insecure_transport_hosts` entry, naming the assumption it encodes. + +Stop passing the key in argv (`:313`) — any local account can read it from the process table. + +- [ ] **Step 1: Write the failing tests, implement, run, commit** + +```bash +git add install-client.sh tests/test_install_client_doctor.py +git commit -m "[hark] Rewrite --doctor for the native agent" +``` + +--- + +### Task 15: Documentation + +**Files:** Modify `README.md` (`:16`, `:138`, `:175`, `:250`, `:387`), `docs/superpowers/specs/2026-07-14-hark-open-source-design.md:7`, `client/hark-config.example.lua` → `client/client.json.example`. Delete `client/rec.swift`, `tests/test_client_record.lua`. + +**Do not delete `client/rec.swift` before Task 13 lands** — `install-client.sh:426` still compiles it, and removing it first bricks the installer mid-run. + +README must state, in the same terms the spec uses: the paste guarantee is **application-granularity only** (it does not catch a focus move within an app), and the clipboard **self-clears after 90 s** as a privacy property, not only as paste-recovery ergonomics. + +Update `:7`'s "(architecture unchanged)". `ef47aeb` already struck the CI and native-app non-goals; `c852392` already fixed the `setup.sh` references — do not redo either. + +- [ ] **Step 1: Update docs, verify counts against a real run, commit** + +```bash +git add README.md docs client +git commit -m "[hark] Document the native client" +``` + +--- + +## Self-review + +**Spec coverage.** Every spec section maps to a task: Phase 0 → 1; server blast radius → 2, 3; architecture → 4-7, 9, 10; hotkey → 1, 8; state machine → 9; status/`--doctor` → 9, 14; release contract → 11, 12; migration → 13; docs → 15. Clipboard retention → 9. Transport → 5. Response bounds → 7. + +**Known gaps, stated rather than hidden.** The Keychain-pinned server origin is deliberately out of scope — it is recorded as a spec follow-up and needs its own design; a same-UID rewrite of `client.json` can still redirect the agent. Nothing here proves TCC survives a Developer ID *certificate rotation*; that only shows up at the second release. + +**Verification status.** The Python code blocks in Tasks 2 and 3 are against code read at `8d12f7b` and are expected to run as written. The Swift blocks are **specifications, not compiled code** — no Swift here was built or run. Treat signatures as the contract and fix compile errors in place; report any that force an interface change rather than silently diverging. diff --git a/docs/superpowers/specs/2026-07-31-native-client-design.md b/docs/superpowers/specs/2026-07-31-native-client-design.md new file mode 100644 index 0000000..0c9f6b9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-native-client-design.md @@ -0,0 +1,623 @@ +# Native client design — replacing the Hammerspoon Lua client + +Issue: [DRYCodeWorks/hark#2](https://github.com/DRYCodeWorks/hark/issues/2) +Date: 2026-07-31 +Revision: 6. Six non-Claude reviewers, three review rounds plus three verification passes: +6/6 REVISE → 5 REVISE + 1 APPROVED → 6/6 REVISE → 4 APPROVED + 2 REVISE → 2 REVISE → +1 APPROVED + 1 REVISE, the last on documentation bookkeeping and one detection heuristic. +Rebased onto `8d12f7b`; every line citation re-verified against that commit. + +This is a design document. It ships no code, so the artifacts it describes — +`client/legacy/init-v1.lua`, the rewritten `install-client.sh` — do not exist yet by +construction. The implementation plan comes next. + +## Why + +`install-client.sh:595` links `~/.hammerspoon/init.lua` to this repo's `client/init.lua`. +Hammerspoon has exactly one config file, so installing hark claims it. + +The permission story matters more. Accessibility is granted to Hammerspoon, not to hark: +a general-purpose scriptable Lua runtime holds a grant that can observe every keystroke, +and its config is a symlink into a git repo, so `git pull` changes what that grant covers +without re-prompting. + +What Hammerspoon buys in return is a **stable signed TCC identity**. Taking that away means +hark owns the identity problem itself, which is why this design assumes a Developer ID. + +## What earlier revisions got wrong + +**Revision 1's hotkey rationale.** It argued for Carbon `RegisterEventHotKey` because it +"asks for permission to type, not permission to watch you type." Synthesising ⌘V requires +Accessibility, and Accessibility is a single atomic grant that also permits creating a +`CGEventTap`. The claimed saving does not exist as stated. + +**Revision 1's collision claim.** It said `RegisterEventHotKey` errors when another app +owns the chord. The installed SDK says the opposite: + +> "The same hot key can, however, be registered by multiple applications... In Mac OS X +> 10.5 and later, you can request exclusive registration for your process only by passing +> `kEventHotKeyExclusive`." +> — `CarbonEvents.h`, `RegisterEventHotKey` discussion + +**Revision 1 fabricated a test claim.** It said `tests/test_audio.py` pins the wire format. +`audio.py:45-49` validates sample width only, so a stereo or 44.1 kHz WAV is accepted today. + +**Revision 2 contradicted itself twice.** It promised a Tailscale HTTP opt-in while +prohibiting non-loopback HTTP, with no mechanism for the opt-in; and it called an +all-silent buffer "distinct from a denied grant" while the error table filed both under +microphone permission. Both are resolved below. + +## Decisions + +| Question | Decision | +|---|---| +| Signing | Developer ID, hardened runtime, notarised and stapled. CI produces the artifact | +| Install | `install-client.sh` downloads and verifies a release artifact. It never compiles | +| App shape | `LSUIElement` bundle, menu bar status item, `~/Applications/Hark.app` (no sudo) | +| Capture | Absorbed in-process; the separate `rec` binary goes away | +| Config | `client.json` holds the server URL and the insecure-transport allowlist; the key always lives in `~/.config/hark/key` mode 600 | +| Hotkey | **Undecided.** Phase 0 decides, against the criteria below | +| Migration | Quit Hammerspoon, offer to revoke all three grants, restore or remove the symlink. Delete nothing else | + +### Why Developer ID, and what it does not buy + +Identical Swift sources produce a different CDHash on every build (measured). Under an +ad-hoc signature that would invalidate the agent's own Accessibility grant on every +rebuild. Revision 1 engineered an installer-side input hash around this; that mechanism is +**deleted**, because CI now produces the binary and the installer never compiles. + +Two corrections to how revision 2 stated the benefit: + +- TCC binds to the app's **designated requirement**, not simply "Team ID plus bundle ID". + CI must therefore keep the designated requirement stable across releases and across + certificate rotation, and Phase 0 must be validated against the **final signed bundle**, + not an ad-hoc spike binary. +- Notarisation requires the hardened runtime, and microphone capture under the hardened + runtime requires the audio-input entitlement. `NSMicrophoneUsageDescription` alone is not + sufficient. The entitlements file is part of the deliverable and CI verifies it. + +The client install no longer needs the Xcode command line tools, which removes a +multi-gigabyte dependency from the machine you dictate *from* — the point of #3, #4 and #5. + +`~/Applications` keeps the install sudo-free; under Developer ID the path is a packaging +choice, not a permission one. + +## Phase 0: the hotkey spike (gates everything else) + +Two questions decide the mechanism. Neither should be answered by argument. + +1. **Does a keyboard `CGEventTap` require Input Monitoring in addition to Accessibility?** + `README.md:266-268` states from this project's own experience that an `hs.eventtap` + watching `flagsChanged` needs it. If that holds for keyDown/keyUp, Carbon is one grant + and the tap is two, and the permission argument survives revision 1's bad reasoning by a + different route. If not, the tap is free. +2. **Is `kEventHotKeyReleased` reliable enough for hold-to-talk?** + +**Pass/fail criteria**, so the spike produces a decision rather than an impression: + +- Test on the oldest supported macOS (13) and the newest available. +- 200 press/release cycles per mechanism; **zero** missed releases required. A missed + release strands the agent in `recording`, which is why the bar is zero rather than low. +- Include modifier rolls (press ⌃⌥Space, add ⇧ mid-hold, release), sustained CPU load, and + a display-sleep/wake cycle mid-hold. +- Register the chord while a second app holds it, with and without + `kEventHotKeyExclusive`, and record whether registration reports the collision. +- Run against the **signed, notarised bundle**, not a bare binary. + +If Carbon wins, `kEventHotKeyExclusive` is mandatory — migration cannot claim anything +about chord ownership without it (see Migration). + +### The zero-frames question is settled, and already fixed upstream + +Revisions 1 and 2 treated this as an open risk. It is neither open nor hypothetical: +inferring microphone permission from a frame count does not work, because a TCC-denied +device yields substituted silence rather than no frames. It was filed as +[#9](https://github.com/DRYCodeWorks/hark/issues/9) and fixed in `559aafe` for the current +Lua client, whose `rec.swift` now says so directly: + +> "format negotiation succeeds under denial too. The only way to learn the answer is to +> ask TCC for it." + +That commit is the reference implementation for this design's permission handling rather +than something it has to invent: `rec.swift:65` switches on +`AVCaptureDevice.authorizationStatus(for: .audio)`, `:78` calls `requestAccess` and pumps +the main run loop rather than blocking on a semaphore (`requestAccess` delivers on an +unspecified queue, so a blocked main thread can deadlock), and the zero-frame check +survives at `:185` for its real meaning — the device delivered nothing at all. + +The native agent inherits all three behaviours. Phase 0 no longer has anything to decide +here. + +## Architecture + +One SPM package. CI runs `swift build` and `swift test`; the release job assembles, signs, +notarises and staples `Hark.app`. + +Bundle ID `com.drycodeworks.hark-agent` — a **new** identifier following the existing +launchd label style, not an existing label. `Info.plist` carries `LSUIElement`, +`NSMicrophoneUsageDescription`, `NSLocalNetworkUsageDescription` (needed for the +two-machine setup on macOS 15+, and separate from ATS), and the ATS keys below. +Entitlements carry the audio-input entitlement under the hardened runtime. + +| File | Responsibility | +|---|---| +| `Config.swift` | Load `client.json`; read and trim the key; enforce the transport policy | +| `Hotkey.swift` | Register the chord (mechanism per Phase 0); press/release; registration failure | +| `Recorder.swift` | AVAudioEngine capture, conversion, in-memory WAV, capture-state reporting | +| `DictateClient.swift` | Bounded POST, decode, typed errors, response sanitisation | +| `AgentController.swift` | State machine, permissions, paste, status heartbeat, logging, menu bar | + +Revision 1 had eight files; `Paster`, `Log`, `Diagnostics` and `StatusItem` each had one +caller and no independent policy. The status item shows state and offers Quit only — +`install-client.sh --doctor` is already the diagnostic interface. + +### Login start + +`SMAppService.mainApp.register()` is called by the **installer as the last step of a +successful cutover** (Migration, step 4), never automatically at first launch. Registering +on launch would mean a rolled-back install still starts the agent at the next login, where +it would fight the restored Hammerspoon client. + +Registration only schedules for *subsequent* logins, so the installer also starts the agent +directly for the current session. Handle `.requiresApproval` (macOS has disabled it pending +user consent) and `.notFound`/error by surfacing state in the menu bar, in the status +heartbeat's `login_item` field, and in `--doctor`, rather than assuming success. + +### Permissions + +Capture permission is never inferred from frame counts: + +- `AVCaptureDevice.authorizationStatus(for: .audio)`, and `requestAccess` at first launch + to raise the dialog. The Microphone pane has no "+" button and lists only apps that have + already asked, so the request must actually happen. +- Probe at **launch**, not at first hotkey press, so a clean install has a status before + anyone runs `--doctor`. +- `AXIsProcessTrustedWithOptions` with the prompt option. `AXIsProcessTrusted` alone only + checks and never prompts, leaving a fresh install with no path to the grant. +- The agent probes local-network reachability itself. A `curl` from `--doctor` is a false + PASS, because terminal-launched tools are exempt from Local Network privacy. + +### Capture + +The wire format is **16 kHz, mono, 16-bit signed PCM, little-endian, single `data` chunk** — +stated explicitly here because `audio.py:22` documents it in a comment while enforcing only +sample width. + +`/tmp/hark.wav` goes away, and with it the stale-file hazard `init.lua:375` guards against. +`Recorder` builds the 44-byte header itself; RIFF and `data` sizes are back-patched before +the POST, since neither is known until release. + +The tap callback runs on an audio thread while key-up runs on the main queue. +`rec.swift:163` mutates `framesWritten` from the callback while `rec.swift:185` reads it +from `stop()` — unsynchronised today, and more consequential in-process. An actor or serial +queue owns the buffer; stop waits for the tap to drain and is idempotent. + +## State machine + +``` +idle ──press──► starting ──first buffer──► recording ──release──► stopping + ▲ │ │ │ + │ └── deadline / failure ──┐ └── cap reached ─────┤ + └──── paste, error, or discard ◄── uploading ◄──── drain complete ──┘ +``` + +| Transition | Behaviour | +|---|---| +| press outside `idle` | ignored | +| `starting`, no first buffer within **5 s** | abort to `idle`, report device or permission | +| `starting` + release | finish starting, then honour the release; never strand | +| engine start fails, or permission denied | abort to `idle` with the specific cause | +| `recording` beyond **120 s** | stop and upload what was captured, with a visible notice | +| `uploading`, request exceeds **30 s** | cancel, return to `idle`, report timeout | +| sleep, or user-session switch | abort capture to `idle` | + +`stopping` exists so a press immediately after release cannot start a second capture while +the first drains — revision 1 would have overlapped captures or dropped the utterance. + +**One in-flight request**, with a sequence number. The state machine alone mostly prevents +overlap, but a timed-out request that completes late must not paste into a newer capture's +turn, so responses whose sequence is not current are discarded. + +A 5 s starting deadline is separate from the 120 s capture cap: today's recorder arms its +ceiling only after the first buffer (`rec.swift:139`), so a device that never delivers one +hangs indefinitely. + +## Data flow + +1. Press. Ignore unless `idle` and the key resolves. +2. Capture starts; status item and overlay show recording. +3. Release. Tap drains; WAV finalised in memory. **Snapshot the frontmost application.** +4. No frames, or an all-silent buffer: report the capture-side cause and stop. Do not POST. +5. POST with `X-Hark-Key` and `Content-Type: audio/wav`. +6. `200` with empty text: transient "heard nothing", paste nothing. Not an error. +7. `200` with text: sanitise, verify the paste target, set the pasteboard, verify the write, + then ⌘V. + +Never Return. The clipboard is deliberately not restored. + +**Paste-target policy.** Transcription is asynchronous, so focus can move between release +and response. If the frontmost **application** is not the one snapshotted at release, put +the transcript on the pasteboard and report that automatic paste was withheld. Typing a +transcript into whatever happens to be focused later is worse than making the user press +⌘V. + +The guarantee is deliberately stated at application granularity, because that is all this +mechanism enforces. It does **not** catch a focus move *within* an application — a +different browser tab, or a password field in the same window. Catching that would mean +comparing the system-wide focused accessibility element, which is a heavier check against a +moving target. Claiming protection this does not deliver would be worse than the narrower +promise, so the README should describe it in the same terms. + +**Paste failure propagates.** If the pasteboard write fails, report it and do **not** +synthesise ⌘V — otherwise the keystroke pastes whatever was on the clipboard before. + +## Response handling and limits + +The server sanitises at `sanitize.py:23`; revision 1 treated that as sufficient. It is not. +A compromised server, or anyone on the network path of a plaintext two-machine setup, +returns JSON the server's sanitiser never touched, and "Never Return" protects nothing when +the payload itself carries `\r` or `\n` into a terminal without bracketed paste. + +| Bound | Value | Applied | +|---|---|---| +| Response body | **1 MiB** | Before JSON decoding, as a streaming byte limit | +| Sanitised text | **8 KiB** (UTF-8 bytes) | After decode and sanitisation | +| Error `detail` | **2 KiB**, sanitised | Same treatment as transcript text | +| Behaviour past either | **Reject, do not truncate** | Rejection is visible; truncation silently corrupts a transcript | + +The body cap must bind before decoding — a post-decode cap still lets a hostile server make +`URLSession` buffer an unbounded response. + +**Every response path is bounded and sanitised, not just the 200.** The error table below +displays the server's `detail` string on 400, 503 and the catch-all, and that string is +just as attacker-controlled as a transcript. An unsanitised `detail` reaches an alert +rather than the pasteboard, so it cannot be typed into a terminal — but it can still carry +control sequences into whatever renders it, and an unbounded one is a denial of service on +the UI. Same rule, smaller cap. + +Sanitisation applies the same rule as `sanitize.py`: C0/C1 controls and Unicode line +separators replaced with a space, whitespace collapsed. The Swift and Python +implementations share test cases, including hostile inputs: embedded newlines, CSI/OSC +sequences, U+2028/U+2029, and an oversized body. + +## Transport policy + +The two-machine default is plaintext HTTP (`install-client.sh:525`) to what +`README.md:234` calls "a LAN address you trust". On that path an attacker reads the audio, +the transcript and `X-Hark-Key`, and can forge the response the client then types. + +Revision 2 prohibited non-loopback HTTP while promising a Tailscale opt-in, with no +mechanism. Resolved: + +```json +{ + "server": "http://100.x.y.z:8911/dictate", + "insecure_transport_hosts": ["100.x.y.z"] +} +``` + +- HTTPS is required for every non-loopback **hostname**, with no exceptions. +- Plain HTTP is permitted for numeric loopback unconditionally, and for a **numeric IP + literal** only if that exact address is listed in `insecure_transport_hosts`. +- The allowlist exists because Tailscale already encrypts at the network layer, so HTTP to + a tailnet IP is a defensible choice — but it must be a stated one, not a silent default. + Provisioning TLS on the hark server would be a larger change than this issue. +- A Tailscale MagicDNS name is therefore **not** usable over HTTP. Use the tailnet IP. +- `--doctor` reports every entry as a warning naming the assumption it encodes. +- Userinfo (`user@host`) rejected; `install-client.sh:241` already treats it as a defect. +- Redirects cancelled explicitly in the `URLSession` delegate — the default session follows + them. +- Ephemeral `URLSession`, so credentials and responses are not cached to disk. + +**ATS: `NSAllowsLocalNetworking` only, and nothing per-host.** Revision 3 said the +installer would generate per-host `NSExceptionDomains` entries. That is impossible: +`NSExceptionDomains` lives in the signed `Info.plist`, so writing to it after download +invalidates the CDHash and the Developer ID signature, and macOS kills the app at launch. + +Restricting insecure HTTP to IP literals is what makes a static plist sufficient — +`NSAllowsLocalNetworking` exempts IP-literal loads without naming any host, so the plist +never has to know which addresses a given user configured. The `Config` allowlist check at +runtime, not ATS, remains the actual boundary. Phase 0 confirms the key behaves this way on +both supported macOS versions, since bare IP handling has changed across releases. + +## Configuration + +The key always lives at `~/.config/hark/key`, mode 600. Revision 1's inline-versus-file +branch is deleted: one path, one permission contract. + +- `install-client.sh` creates `~/.config/hark` mode 700 before writing. On a two-machine + *client* the directory does not otherwise exist — the server-side `mkdir` ran on the + other machine. +- The key is read and **trimmed**: `config.py:129` writes `key + "\n"` and + `install-client.sh:449` strips it today. Raw bytes 401 every request. Embedded whitespace + is rejected rather than silently trimmed. +- `client.json` is written by a real JSON encoder and replaced atomically. The current + heredoc (`install-client.sh:563`) validates quotes only in the key (`:491`), so a + hand-entered URL containing a quote produces an unparseable file. +- `--doctor` stops passing the key in argv (`install-client.sh:313`), where any local + account can read it from the process table. + +**Known residual risk.** Any same-UID process can rewrite `client.json` and point the +trusted agent at an attacker's server; mode 600 does not prevent this. Under Developer ID +the mitigation is available — pin the approved origin in a Keychain item protected by code +identity, requiring in-app confirmation to change. Recorded as a follow-up; it needs its own +design. + +## Error handling + +| Condition | Message names | +|---|---| +| Connection / transport failure | The URL, and that hark may not be running | +| 401 | Key mismatch, and the file to fix | +| 415 | A client bug, not a microphone problem | +| 400 | Malformed or unsupported audio — a client format bug. Shows the server's `detail` | +| 503 | whisper-server down, `/tmp/hark-whisper.err` | +| Other | Status code and the server's `detail` | +| No frames captured | Device selection, and microphone permission | +| All-silent buffer | Input level or a muted device — **not** reported as a denied grant | + +The last two rows resolve revision 2's contradiction. A denied grant is detected by +`authorizationStatus`, never inferred from audio content, so an all-silent buffer means the +device is muted or the level is too low. + +**The server must change too.** `app.py:103-107` returns one detail for every +`InvalidAudioError` — "Check that the client has microphone permission and is sending +16 kHz mono 16-bit PCM WAV" — so a malformed header from this client's hand-built WAV would +still send users to Microphone settings. `test_app.py:107` asserts that wording +(`assert "microphone" in detail or "mic" in detail`). + +`audio.py` already raises distinct causes: empty body, unreadable WAV, wrong sample width. +Branch on them — empty body keeps the microphone advice, which is right for any client; +malformed or unsupported audio gets format advice. Update the test to match the cause it +exercises. + +## Status and `--doctor` + +`--doctor` must not run the agent binary itself: a probe launched from the terminal tests +the *terminal's* TCC grant, the trap `install-client.sh:177-184` already documents. + +Revision 2's "generation stamp" was not implementable — `--doctor` is bash and cannot read +the agent's internal state. The binding must be independently observable: + +```json +{ + "pid": 4321, + "process_started": "Fri Jul 31 14:02:11 2026", + "written_epoch": 1785508262, + "bundle_version": "1.2.0", + "microphone": "authorized", + "accessibility": "trusted", + "local_network": "ok", + "hotkey": "registered", + "login_item": "enabled" +} +``` + +- `process_started` is the **verbatim output of `ps -o lstart= -p `**, read by the + agent about itself at launch. An ISO-8601 timestamp cannot work here: `ps -o lstart=` + prints a localised, non-ISO string (`Fri Jul 31 14:02:11 2026`, with trailing padding), + so `--doctor` would need brittle date conversion to compare. Storing what `ps` prints + makes the check a trimmed string equality. +- `written_epoch` is integer seconds, so bash compares it with `$(date +%s)` and no parsing. +- The agent rewrites this every **30 s** and on every permission change. +- `--doctor` fails unless the PID is alive, `ps -o lstart= -p ` matches + `process_started` after trimming, and `written_epoch` is within **90 s** of now. +- `hotkey` and `login_item` are in the heartbeat because a fresh status file otherwise + proves only that a process is running with permissions. It would pass with an + unregistered hotkey or a login item macOS has disabled — both of which mean the product + does not work. `--doctor` requires both. +- No agent running is a FAIL, not a skipped check. +- Written atomically, temp file plus rename. Revision 1 would have let `--doctor` read + partial JSON, and today `install-client.sh:204-208` accepts any existing `ok` line, so a + stale file survives a revoked grant and reports PASS. + +Permissions are re-read after wake and on activation, so revocation during a long-running +agent is reflected rather than cached from launch. + +## Release and install contract + +"Downloads the artifact" is not a design. The installer replaces code that holds TCC grants, +so: + +- **Artifact**: a universal (arm64 + x86_64) `Hark.app`, zipped, attached to a GitHub + release, with the tag as the version. `install-client.sh` selects the newest release + unless pinned. +- **Verification splits across CI and the client**, because they have different tools + available: + - **In CI**, where Xcode exists: `xcrun stapler validate` confirms the notarisation + ticket is actually stapled to the artifact before it is published. This is the only + place `stapler` is used. + - **On the client**, using only stock macOS binaries: `spctl --assess --type execute -vv` + (`/usr/sbin/spctl`) and `codesign --verify --deep --strict -R` (`/usr/bin/codesign`) + against an expected requirement naming the **Team ID and bundle ID**. + + Revision 4 called for `xcrun stapler validate` on the client. That contradicted this + document's own claim that the client no longer needs the Xcode command line tools — + `stapler` ships with Xcode, not with macOS. Stapling is a release-time property, so it + belongs where the toolchain already exists. + + **The installer must set `com.apple.quarantine` on the extracted app itself, before + `spctl` runs.** The argument that "the download is quarantined anyway" does not hold for + a scripted install: the attribute is applied by browsers and other LaunchServices-aware + downloaders, and a `curl` fetch followed by `ditto`/`unzip` need not preserve it. Without + the attribute, `spctl --assess` takes a different and weaker path, so the notarisation + check the client relies on would quietly not be the check it thinks it is. Set it + explicitly, then assess. + + `spctl` alone is a Gatekeeper verdict and notarisation alone only proves *someone* signed + it — the requirement string is what proves it was *you*. +- **Replacement**: download to a staging directory, verify, quit any running agent, replace + atomically, then launch and confirm the status heartbeat appears with `hotkey` and + `login_item` both healthy. +- **Rollback**: keep the previous bundle until the new one reports healthy, and on failure + restore *and relaunch* it. Leaving the user with no running client is its own outage. +- Failure at any step leaves the existing install untouched. + +## Testing + +No permissions or microphone needed: config loading, key trimming and rejection, +transport-policy enforcement including the allowlist, the WAV header builder with +back-patched sizes, status-to-error mapping via a stubbed `URLProtocol`, response bounds +(1 MiB body, 8 KiB text, reject-not-truncate), and hostile-response sanitisation. + +Server-side, fix the coverage revision 1 wrongly claimed existed: `audio.py` validates +sample width only, so add assertions and server rejection for channel count and sample rate +alongside bit depth, and update the 400-detail test to the branched causes. + +CI runs `swift build`, `swift test`, the pytest suite, and verifies the signed bundle's +entitlements and designated requirement. + +Not testable in CI, as plainly as the README states its own limits: TCC grants, real hotkey +delivery, real paste into a real window. + +## Migration + +**Nothing outside hark's own files is deleted.** But "report and move on" is insufficient: + +- A running Hammerspoon holds the old Lua **in memory**. Replacing the symlinked file + changes nothing until it reloads, so both clients bind ⌃⌥Space. + `install-client.sh:608` already documents that it does not auto-reload. +- Leaving Hammerspoon's grants preserves exactly the privilege escape motivating this + issue. Any same-UID process can later rewrite the symlink target and reload it. + +### Staged cutover + +Revisions 2 and 3 contained a deadlock: they required the native agent to be verified +healthy *before* Hammerspoon is quit, while the agent cannot register the hotkey at all +while Hammerspoon owns it — certainly not under `kEventHotKeyExclusive`. Health-checking a +hotkey the old client still holds is not possible, so registration is split out of the +health check and happens after the handover: + +0. **The rollback target ships in the repo, not copied at install time.** The last + functional Lua client is preserved as a versioned asset at + `client/legacy/init-v1.lua`, committed in the same change that stubs `client/init.lua`. + + It cannot be copied from the live file, which is the mistake revision 5 made. By the + time the updated installer runs, the user has already pulled, so `client/init.lua` *is* + the stub — and after a repo move the symlink is dangling and resolves to nothing at all. + The working code would survive only in git history and in Hammerspoon's memory, neither + of which the installer can read. A committed asset is the only source that is + guaranteed present and functional at install time. + + The installer copies it to `~/.config/hark/legacy-client.lua` before cutover, and + rollback repoints the symlink there. +1. **Detect a hark-era install from pre-existing evidence only**, in this order: + 1. a `~/.config/hark/legacy-client.json` ownership record written by an **earlier** run, + **and** whose recorded target matches the symlink's current target; + 2. otherwise, a symlink whose target carries the hark marker comment; + 3. otherwise, a *dangling* symlink whose path matches a known hark layout — **a + heuristic, not proof.** When this is the *only* evidence, the installer must stop and + ask for explicit confirmation before quitting Hammerspoon, replacing the symlink, or + offering any TCC reset. A path that merely looks like hark's can belong to a + Hammerspoon config someone manages themselves, and every action gated behind this + detection is disruptive and not silently reversible. + + A real file, or a symlink without marker or record, means the user runs Hammerspoon + independently — do not quit it, do not offer to revoke anything, and ask before + proceeding. + + **The ownership record is written only after ownership is confirmed**, at the end of a + successful cutover, for the benefit of future runs. Revision 5 had step 0 write the + record and step 1 immediately trust it, which is circular: it would classify any + symlink as hark's own, including an independently managed one. It also assumed a record + that no existing installation can have, since this change introduces it. +2. **Stage the rollback target and install the agent.** Copy `client/legacy/init-v1.lua` + to `~/.config/hark/legacy-client.lua` and confirm it is readable before anything is + changed. Then health-check the agent **with the hotkey disabled** and login registration + **not yet performed**: bundle signature, launch, permissions, status heartbeat, server + reachability, and key authentication. +3. **Quit Hammerspoon** and wait for process exit. Chord ownership cannot be queried — + macOS exposes no way to ask WindowServer who owns a hotkey — so confirmed process exit + is the strongest available signal. Revision 2's "confirms it no longer owns the chord" + overstated what is possible. +4. **Tell the running agent to register the hotkey**, verify it succeeded, and only then + call `SMAppService.mainApp.register()`. +5. **On failure at step 4, roll back in this order**: stop the agent, ensure + `SMAppService.mainApp` is **not** registered (unregister if step 4 got that far), point + `~/.hammerspoon/init.lua` at `~/.config/hark/legacy-client.lua`, relaunch Hammerspoon, + confirm it is running, then report. The user ends with a working client either way. + + Deregistration is the part revision 4 missed: it left the agent "installed but inactive" + while registration ran unconditionally at first launch, so the agent would start at the + next login and fight the Hammerspoon that had just been restored. Registration is + therefore deliberately the *last* step of a successful cutover, not part of launch. +6. **Only on success**, write the `~/.config/hark/legacy-client.json` ownership record — + confirmed ownership, the symlink's original target, and a checksum — for future runs to + read. Then remove the symlink hark created, and offer to revoke **all three** grants: + `tccutil reset Accessibility`, `Microphone`, and `ListenEvent`, for + `org.hammerspoon.Hammerspoon`. Input Monitoring is included because `README.md:266-268` + told Fn-key users to grant it. +7. If the user declines revocation, say plainly that the original exposure remains. The + install proceeds; it is not silently reported as closed. +8. `~/.config/hark/legacy-client.lua` is kept, not deleted, so a later manual rollback is + still possible. It is inert once the symlink is gone. + +Removing `client/rec.swift` while `install-client.sh:426` still compiles it would brick the +installer mid-run, so that ordering holds too. + +`client/init.lua` becomes a **silent** no-op stub, for symlinks this installer never sees. + +## Blast radius + +Line numbers are against `8d12f7b`. In scope for the implementation, not follow-ups: + +- **`install-client.sh`** — replace the Hammerspoon install, the `swiftc` build (`:419`), + the launch block (`:613`), and every `--doctor` check: `check_hammerspoon_installed` + (`:86`), the Lua-config key grep (`:127`), the recorder check (`:173`), the mic-status + read (`:196`), the Hammerspoon TCC query (`:679`), and the probe wait (`:742`). +- **`src/hark/app.py`** (`:103-107`) and **`tests/test_app.py`** (`:107`) — branch the 400 + detail by cause. +- **`src/hark/audio.py`** and **`tests/test_audio.py`** — enforce and assert channel count + and sample rate, not just sample width (`:45`). +- **`README.md`** — architecture (`:16`), install step 1 (`:138`), the `--doctor` list + (`:175`), "Changing the hotkey" (`:250`), two-machine setup, repo layout (`:387`). +- **Add** `client/legacy/init-v1.lua` — a verbatim copy of the last functional Lua client, + committed in the **same** change that stubs `client/init.lua`. It is the rollback target + and must not be stubbed, linted into a stub, or "cleaned up" later; `tests/` should assert + it still binds a hotkey, so a future tidy-up cannot silently empty it. +- **Retire** `client/rec.swift`, `tests/test_client_record.lua`, and + `client/hark-config.example.lua`, replaced by a `client.json` example. +- **`docs/superpowers/specs/2026-07-14-hark-open-source-design.md:7`** — "(architecture + unchanged)", which this design invalidates. +- Update the status line of `2026-07-14-dictate-design.md` (`:10`), still "Design approved, + pending implementation plan". + +**Already done upstream, and deliberately not repeated here.** `ef47aeb` struck the two +non-goals this design contradicts — "CI, release automation, versioning, changelogs" +(`:219`) and "Replacing Hammerspoon with a native Swift menubar app" (`:228`) — and added a +"Superseded in part" header at `:211`. `c852392` cleared the stale `setup.sh` references +(#8's sibling work). Only `:7` above remains. + +## Non-goals + +- The Fn/🌐 key. Revisit only if Phase 0 selects a tap. +- Pre-warming the audio engine. In-process capture makes it possible; the README's privacy + trade-off is deliberate and stands. +- Streaming transcription and auto-submit, per the 2026-07-14 spec. +- TLS on the hark server. The `insecure_transport_hosts` allowlist is the interim answer. +- Keychain-pinned server origin. Follow-up, recorded above. + +## Clipboard retention + +Two reviewers asked for this to be decided rather than left open, and they are right: it is +a privacy policy, not a preference. + +**The transcript self-clears after 90 seconds, but only if `NSPasteboard.changeCount` is +unchanged since hark wrote it.** + +The existing behaviour — set and never restore — is deliberate and stays: a misfired paste +stays recoverable with a manual ⌘V instead of re-speaking. See the comment above +`hs.pasteboard.setContents` in `client/init.lua`. But "never restore" currently means +*indefinitely*, and any local process can poll `NSPasteboard` and harvest every transcript +without holding a single TCC grant. That is a real exposure the design was silently +inheriting. + +90 seconds keeps the recovery window that motivated the original decision while bounding +the exposure. The `changeCount` guard means hark only ever clears its own value — if you +copied something else in the meantime, hark does nothing. + +The README should state this as a privacy property, not only as paste-recovery ergonomics. + +## Open questions + +- Phase 0's two answers, which decide the hotkey mechanism.