Skip to main content

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:

  1. A CLI is wanted. public/cli/spec/CLI.md reserves aucert 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.
  2. 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.
  3. The protocol is implemented twice. The Kotlin relay-protocol module 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-protocol rather than re-implementing it. The dual-implementation drift class disappears.
  • The console's window.aucertNative bridge 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​

OptionProsCons
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 clientAlready built, tested, green; best-quality Mac integration; zero rework todayCLI impossible without a second implementation; no Windows/Linux path; protocol stays implemented twice, forever
Go or Rust agentSmall static binaries, fast startup, excellent cross-compilationFifth 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 subprocessrelay-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 portabilityThe 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 agent is the same binary as aucert 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 FakeCommandRunner and 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 adb on 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 agent cannot 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 0600 file in ~/.aucert/ matches what gh, aws, docker, and kubectl do, 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.1 with a per-session bearer token in a 0600 file; 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