ADR-028: Client agent runtime is Kotlin/JVM, hosted by a thin native shell
Context
SPEC-074 ships a macOS app so trial customers run Rover against devices on their
own Mac. The first implementation put the whole client in Swift: device
discovery, relay control channel, data pipe, enrollment, credential storage —
6,007 lines across clients/macos/Sources/AucertMac/.
Three forcing functions arrived after that code was written and before any of it merged:
- A CLI is wanted.
public/cli/spec/CLI.mdreservesaucert run,aucert status,aucert init. For CI and terminal-first use, the CLI must reach a local device — which means the CLI needs the agent. A GUI app that must be running for a terminal command to work is not an acceptable design, and it is useless in CI. - Windows/Linux are wanted later. SPEC-074 open question #7 already noted the agent's duties (discovery, dial-out, pipe) are portable and the relay protocol must not assume macOS.
- The protocol is implemented twice. The Kotlin
relay-protocolmodule is the contract; the Swift client re-implements it by hand (RelayFrames/RelayProtocol/RelayCodec, 1,660 lines, with JSON fixtures kept in sync manually). This duplication has already cost us: the v2 protocol amendment required two separate follow-up sync tasks, one per implementation.
The constraint that rules the decision: do not add a language the team does not already run. The stack is Kotlin/JVM, TypeScript, Python — plus Swift, added by this feature. Go or Rust would be a fifth.
Decision
The client agent is Kotlin/JVM, shipped as the aucert CLI. The macOS app
becomes a thin native shell that spawns and supervises that CLI as a child
process. Swift retains only what requires macOS frameworks (AppKit, WebKit,
IOKit, Security).
- One agent implementation, three delivery surfaces: the macOS app (bundles the binary), the standalone CLI (CI/terminal), and future Windows/Linux hosts.
- Swift ↔ agent communication is inter-process, never FFI: the shell spawns the agent and talks to it over a loopback IPC channel (SPEC-076).
- The agent imports
//backend/yard/relay-protocolrather than re-implementing it. The dual-implementation drift class disappears. - The console's
window.aucertNativebridge contract is unchanged; the Swift bridge becomes a proxy to the agent. No console code changes.
Nothing on the cloud side changes: relay, registry, enrollment, tunnel provider, and the Rover integration are untouched by this ADR.
Alternatives considered
| Option | Pros | Cons |
|---|---|---|
| Kotlin/JVM agent + thin Swift shell (chosen) | No new language; imports the protocol module (kills 1,660 duplicated lines and the drift class); reuses CommandRunner, Ktor client WS, and the relay's proven PipeBridge backpressure model; JVM runs on Windows/Linux; JVM tests run in CI (the Swift suites could not execute on a CLT-only toolchain) | Needs a bundled runtime (jlink/jpackage or GraalVM native-image); ~3,600 lines of Swift get ported, ~1,660 deleted |
| Keep the all-Swift client | Already built, tested, green; best-quality Mac integration; zero rework today | CLI impossible without a second implementation; no Windows/Linux path; protocol stays implemented twice, forever |
| Go or Rust agent | Small static binaries, fast startup, excellent cross-compilation | Fifth language in the stack (the stated constraint); throws away relay-protocol, CommandRunner, and the PipeBridge patterns; protocol still implemented twice |
| Kotlin/Native framework imported by Swift (KMP) | Single Kotlin source; no subprocess | relay-protocol must become multiplatform (java.time → kotlinx-datetime); Kotlin/Native + Bazel + SwiftPM is poorly-trodden; couples GUI and agent lifetimes into one process |
| Flutter (or other cross-platform UI) | UI portability | The app has no bespoke UI — the console is the UI. Every device duty (adb, sockets, Keychain, menu bar) still needs per-platform native plugins. Buys portability we do not use and does not remove the native work. |
Consequences
What becomes easier
- The CLI becomes possible at all.
aucert agentis the same binary asaucert run; CI and headless Linux work without a GUI. - The protocol has one implementation. Contract changes stop requiring paired, hand-synced client edits.
- Windows/Linux become a packaging exercise, not a rewrite — modulo iOS
simulators, which are macOS-only by Apple's constraint (
xcrun simctl). - Tests get more honest. Agent logic moves onto the JVM, where the existing
FakeCommandRunnerand the in-process relay integration harness already live — and where CI can actually execute them. - The Mac app exercises the CLI path continuously, because it is the CLI's first consumer. The headless path cannot silently rot.
What becomes harder
- A runtime must ship with the client. jlink/jpackage adds ~30–50 MB to the
bundle; GraalVM native-image avoids it at the cost of reflection config and
build complexity. (Mitigating context: the client already requires
adbon the host, so bundling a runtime is not a new category of burden.) - Two processes instead of one — spawn, supervise, restart, and a
single-instance lock so an app-spawned agent and a hand-run
aucert agentcannot both claim the same device. - Credential custody moves off the Keychain to a cross-platform file store (see risks).
- JVM startup cost on short CLI commands (~200–500 ms). Irrelevant for
aucert run; native-image is the escape hatch if it ever grates.
Risks
- Credential-at-rest is weaker than Keychain. A
0600file in~/.aucert/matches whatgh,aws,docker, andkubectldo, and the access token is short-lived (10 min) with a revocable refresh token — but it is a real reduction from Keychain. OS-keystore integration per platform is a named follow-up, not a v1 blocker. - Loopback IPC is a local attack surface. Bound to
127.0.0.1with a per-session bearer token in a0600file; Unix-domain sockets / named pipes are the stronger follow-up (SPEC-076 open question). - Rework of unmerged work. ~3,600 Swift lines are ported and ~1,660 deleted. This is affordable only because none of it has merged — the cost rises steeply after that, so the decision must land before the Swift agent PRs do.
- Bundled-runtime packaging is new ground for us (jpackage/native-image under Bazel). Scoped as its own SPEC-076 task with a fallback (ship the app bundle first, standalone CLI archives second).
References
- SPEC-074 (Aucert for Mac — the feature this re-plans the client for)
- SPEC-076 (CLI + client architecture — the design this ADR authorizes)
- ADR-003 (monolith-first), ADR-022 (standalone Rover scan worker)
backend/yard/relay-protocol/README.md— the language-neutral wire contract that makes the client language a reversible decision