Compare commits

..
Author SHA1 Message Date
Bailey Dixon ed9dafe571 Merge pull request #62 from Codename-11/fix/voice-barge-in-crash-0.8.1
release(android): android-v0.8.1 — voice barge-in crash hotfix
2026-05-26 21:33:23 -04:00
Bailey Dixon 851dfc7f25 Merge remote-tracking branch 'origin/main' into fix/voice-barge-in-crash-0.8.1 2026-05-26 21:23:32 -04:00
Bailey DixonandClaude Opus 4.7 b0df2c83f5 release(android): android-v0.8.1
Patch release: fixes the voice-mode barge-in crash on the legacy TTS
playback path (ExoPlayer audioSessionId read off-main). versionCode
10 -> 11. No new features — ADR 33 / persistent-session work stays on
dev for the next minor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:22:00 -04:00
Bailey DixonandClaude Opus 4.7 a8a4bc7514 fix(android): read ExoPlayer audio session id on main thread
Voice chat crashed the instant Hermes began replying when barge-in was
enabled and audio used the legacy /voice/synthesize (Media3) path:

  IllegalStateException: Player is accessed on the wrong thread.
  Current thread: 'DefaultDispatcher-worker-4', Expected thread: 'main'

BargeInListener runs its mic reader on Dispatchers.IO and, to attach
AcousticEchoCanceler, polls an audioSessionIdProvider lambda. On the
legacy path that provider read exoPlayer.audioSessionId directly.
ExoPlayer is thread-confined — its getAudioSessionId() getter calls
verifyApplicationThread() and throws off-main. (The realtime PCM path
was immune: it provides an AudioTrack session id, which is thread-safe.)

VoicePlayer.audioSessionId now serves a @Volatile cache populated from
main-thread Media3 callbacks (AnalyticsListener.onAudioSessionIdChanged
plus a belt-and-braces read in onIsPlayingChanged), so it is safe to
read from any thread.

Adds VoicePlayerTest coverage: the getter reflects the cached id, never
re-invokes the thread-confined getter, and defaults to 0 before the
audio track is allocated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:20:43 -04:00
Bailey Dixon 65db2e60d4 docs: add relay architecture spec page 2026-05-26 19:30:54 -04:00
11 changed files with 201 additions and 98 deletions
+6
View File
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
## [0.8.1] - 2026-05-26
### Fixed
- **Voice mode crash with barge-in on legacy TTS playback.** When barge-in was enabled and the relay served audio over the legacy `/voice/synthesize` (Media3) path, the first agent sentence played for ~2 syllables and then the app crashed with `IllegalStateException: Player is accessed on the wrong thread`. The barge-in listener's `Dispatchers.IO` reader was reading `ExoPlayer.getAudioSessionId()` (a thread-confined accessor) to attach the echo canceller. `VoicePlayer.audioSessionId` now serves a `@Volatile` cache populated from main-thread Media3 callbacks, so it is safe to read from any thread.
## [0.8.0] - 2026-05-23
### Added
+14
View File
@@ -1,5 +1,19 @@
# Hermes-Relay — Dev Log
## 2026-05-26 — Fix voice-mode crash: ExoPlayer audio session id read off-main (barge-in + legacy TTS)
**Report.** Discord user, sideload latest: voice chat crashes the instant Hermes starts answering — "I hear just 2 letters and it crashes." Stack: `IllegalStateException: Player is accessed on the wrong thread. Current thread: 'DefaultDispatcher-worker-4', Expected thread: 'main'` with the Media3 `player-accessed-on-wrong-thread` doc link and a `Suppressed: ... Dispatchers.IO`.
**Root cause.** `Dispatchers.IO` threads are named `DefaultDispatcher-worker-N` (IO and Default share one scheduler pool), so the crash is on an IO coroutine. `BargeInListener` runs its mic reader on `Dispatchers.IO` and, to attach `AcousticEchoCanceler`, polls an `audioSessionIdProvider` lambda. On the **legacy `/voice/synthesize` (Media3) playback path**, `VoiceViewModel` wires that provider to `{ player.audioSessionId }` → `exoPlayer.audioSessionId`. ExoPlayer is thread-confined; its `getAudioSessionId()` getter calls `verifyApplicationThread()` and throws when read off-main. The realtime PCM path is unaffected because it wires the provider to an `AudioTrack` session id (thread-safe), which is why the bug only hit legacy/fallback setups. Sequence: first sentence starts → `runPlayWorker.onFileReady` → `startBargeInListenerIfEnabled()` → IO reader → `awaitNonZeroSessionId()` → off-main getter → crash ~2 syllables in.
**Fix.** `VoicePlayer.audioSessionId` now serves a `@Volatile cachedAudioSessionId` instead of the raw thread-confined getter. The cache is populated from main-thread Media3 callbacks: an `AnalyticsListener.onAudioSessionIdChanged` hook (authoritative, fires when Media3 allocates/reallocates the AudioTrack) plus a belt-and-braces read inside the existing `onIsPlayingChanged`. Reads from any thread are now safe.
**Tests.** Added `VoicePlayerTest` coverage: getter reflects the analytics-listener-cached id, never re-invokes `exoPlayer.audioSessionId` (the off-main call), and defaults to 0 before allocation. Captured the `AnalyticsListener` in the MockK harness. Verified locally: `:app:lintGooglePlayDebug` + `:app:testGooglePlayDebugUnitTest --tests VoicePlayerTest` both green (BUILD SUCCESSFUL).
**Next.** Cherry-picked onto `android-v0.8.1` hotfix branch (off the `android-v0.8.0` tag) for a focused patch release; merges back to `dev` after.
---
## 2026-05-23 — Un-defer the voice/audio test suite (issue #32) + barge-in resume bug
**Context.** GitHub issue #32 tracked 5 voice/audio unit tests `@Ignore`'d during the v0.5.1 release because the full `:app:testGooglePlayDebugUnitTest` task "hung indefinitely." Scope had quietly grown to **8** ignored classes (3 of the "pure-logic, should-work" ones got swept in defensively). Branch `fix/voice-test-suite`.
+14 -66
View File
@@ -1,85 +1,33 @@
# Hermes-Relay-Android v0.8.0
# Hermes-Relay-Android v0.8.1
**Release Date:** May 23, 2026
**Since v0.7.0:** Google Play-safe Bridge Core hardening, provider-native Realtime Agent voice with reliable low-latency playback, a text + mic Voice Lab, connection diagnostics, and clearer Voice Settings.
**Release Date:** May 26, 2026
**Since v0.8.0:** A focused patch fixing a voice-mode crash. No new features.
v0.8.0 is the Android release-prep build for resubmitting the enhanced Google Play track. The Play artifact keeps chat, profiles, voice, terminal/TUI relay, media, notification companion, relay sessions, QR pairing, and diagnostics, while leaving AccessibilityService-backed Device Control only in the sideload flavor.
v0.8.1 is a patch release. If you don't use voice mode with barge-in enabled, v0.8.0 is unaffected — but updating is still recommended.
---
## Download
v0.8.0 ships in two Android build flavors. APK and AAB filenames are version-tagged:
v0.8.1 ships in two Android build flavors. APK and AAB filenames are version-tagged:
| Flavor | File | Who it's for |
|---|---|---|
| Google Play | `hermes-relay-0.8.0-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, wake locks, or unattended phone control. |
| sideload | `hermes-relay-0.8.0-sideload-release.apk` | Direct-install APK for full Device Control testing. Installs as `com.axiomlabs.hermesrelay.sideload`. |
| googlePlay APK | `hermes-relay-0.8.0-googlePlay-release.apk` | Parity/testing artifact. |
| sideload AAB | `hermes-relay-0.8.0-sideload-release.aab` | Parity/testing artifact. |
| Google Play | `hermes-relay-0.8.1-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, wake locks, or unattended phone control. |
| sideload | `hermes-relay-0.8.1-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
| googlePlay APK | `hermes-relay-0.8.1-googlePlay-release.apk` | Parity/testing artifact. |
| sideload AAB | `hermes-relay-0.8.1-sideload-release.aab` | Parity/testing artifact. |
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
---
## Highlights
## Fixed
### Google Play Bridge Core
### Voice mode crash with barge-in on legacy TTS playback
- Google Play now ships Bridge Core without AccessibilityService-backed Device Control.
- The Play artifact keeps pairing, profiles, chat, voice, terminal/TUI relay, media, notification companion, relay sessions, connection status, and diagnostics.
- Sideload remains the only track for screen reading, gestures, screenshots, SMS/call/contact/location helpers, overlays, wake locks, and unattended control.
- User docs, feature matrix, privacy/security copy, and Play listing notes now match that artifact boundary.
Starting voice mode with **barge-in enabled** while the relay served audio over the legacy `/voice/synthesize` path crashed the app the instant the agent began speaking — the first word or two played, then the app died with `Player is accessed on the wrong thread`.
### Provider-native Realtime Agent
The barge-in listener reads the audio session id from a background thread to attach the echo canceller, but Media3's `ExoPlayer` is thread-confined and throws when its `audioSessionId` getter is read off the main thread. `VoicePlayer.audioSessionId` is now backed by a thread-safe cache populated from main-thread playback callbacks, so it's safe to read from any thread.
- Realtime Agent is now a true provider-native voice engine rather than a render-after-Hermes fallback.
- Android streams mic PCM to the relay; the relay opens a server-side xAI or OpenAI realtime session.
- Hermes remains the only path for tools, memory, research/current-data checks, confirmations, side effects, profile context, and durable transcript state.
- The provider receives compact Hermes function results and speaks natural post-tool summaries instead of reading raw tool output aloud.
### Reliable, low-latency realtime playback
- Fixed silent / choppy first-turn realtime audio: the AudioTrack deep-buffer cold-start was parking the playback head at zero. The streaming buffer was shrunk (4000ms → 700ms), the low-latency prebuffer retuned, and a preroll force-start removed for reliable playback from the first frame.
- Added playback diagnostics — time-to-first-audio, requested-vs-actual buffer logging, a first-frame watchdog, and a drain cross-check — so cold-start and underrun issues surface in the Diagnostics log.
### Voice Lab (text + mic demos)
- The realtime voice test screen offers a **Text demo** (raw provider TTS) and a **Mic demo** (full agent path: real speech recognition, Hermes brokering, spoken reply) with tap-to-record / tap-to-stop capture.
- The Voice Lab waveform now follows the playback cursor (driven by the player's amplitude at the playback position) instead of socket-arrival time, so the visual matches what is heard.
### Voice Settings and diagnostics
- Voice Settings now separates **Voice Engine** from global controls.
- The active engine controls the visible card: **Hermes Chat + Voice Output** or **Realtime Agent**.
- The fallback TTS card is global and remains visible for both engines.
- **Test Current Engine** plays the stable voice-output sample in stable mode and opens a provider-native Realtime Agent test session in Realtime Agent mode.
- Settings now has app-level Diagnostics, and API / Relay / Session detail drawers include sanitized recent activity tails.
- Voice turns preflight relay health and use shorter timeouts so a hung relay surfaces as a connection error instead of an indefinite Thinking state.
---
## Google Play Resubmission Notes
- Upload `hermes-relay-0.8.0-googlePlay-release.aab`.
- Play Console release notes can use the `docs/play-store-listing.md` **Release Notes** section.
- The merged Play manifest should contain no `AccessibilityService`, `BIND_ACCESSIBILITY_SERVICE`, `SYSTEM_ALERT_WINDOW`, `FOREGROUND_SERVICE_SPECIAL_USE`, `WAKE_LOCK`, `SEND_SMS`, `CALL_PHONE`, contacts, or location permissions.
- The Play listing should not claim screen reading, screenshots, phone control, or accessibility automation.
## Verification
- Android version metadata: `0.8.0` / `versionCode 10`.
- Google Play release Kotlin compile passed.
- Google Play release AAB build passed.
- Google Play merged manifest has no forbidden Device Control entries.
- Google Play AAB is release-signed with `CN=Bailey Dixon, OU=Hermes-Relay, O=Codename-11`.
- VitePress user-docs build passed.
- Focused voice engine, realtime playback (buffer policy / amplitude / watchdog), and diagnostics unit tests passed.
## Post-install Smoke
- Install/update the sideload APK over the existing sideload app with `adb install -r`.
- Existing pairing should survive a same-flavor update. Re-pair only if you uninstall app data, switch flavor/applicationId, revoke the device, or intentionally clear the server session store.
- Confirm Settings -> Connections shows API, Relay, Session, and Diagnostics activity.
- Confirm Settings -> Voice shows the selected engine card and Test Current Engine follows the selected path.
- In Voice mode, test Hermes Chat + Voice Output first, then opt into Realtime Agent and ask a current-data question to confirm Hermes is queried instead of the provider guessing.
This only affected the **opt-in** barge-in feature on the legacy text-to-speech path; the provider-native Realtime Agent and Voice Output paths were never affected.
+5 -23
View File
@@ -1,25 +1,7 @@
v0.8.0 - Play-safe Bridge Core, Realtime Agent, and diagnostics
Google Play
* Google Play keeps chat, profiles, voice, terminal/TUI relay, media,
notification companion, relay sessions, QR pairing, and diagnostics.
* AccessibilityService-backed screen reading, phone control, screenshots,
SMS/calls, contacts/location, overlays, wake locks, and unattended control
remain sideload-only.
v0.8.1 - Voice mode crash fix
Voice
* Voice Settings now separates Voice Engine from global controls.
* Hermes Chat + Voice Output and Realtime Agent show their own focused cards.
* Fallback TTS stays visible as a global safety-net card.
* Test Current Engine now plays saved stable voice or a provider-native Realtime Agent sample.
* Realtime Agent uses provider-native xAI/OpenAI speech while Hermes remains
the tool, memory, profile, confirmation, and current-data authority.
* Realtime voice now plays reliably from the first frame with low latency —
fixed silent/choppy first-turn audio and added playback diagnostics.
* The Voice Lab adds a Text demo (raw provider TTS) and a Mic demo (full agent
path with tap-to-record/stop); the waveform follows the playback cursor.
Diagnostics
* Settings now has app-level diagnostics.
* Connection detail sheets show recent API, relay, session, endpoint, and voice
activity so relay hangs surface quickly.
* Fixed a crash that could hit voice mode when barge-in was enabled on the
legacy text-to-speech path — the agent's first words no longer cut off
into a crash. Barge-in is opt-in; the Realtime Agent and Voice Output
paths were never affected.
@@ -9,6 +9,7 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -83,9 +84,33 @@ class VoicePlayer(
private var visualizer: Visualizer? = null
private var visualizerAttached = false
// Thread-safe mirror of [ExoPlayer.getAudioSessionId]. ExoPlayer is
// thread-confined — every accessor (the audioSessionId getter included)
// calls verifyApplicationThread() and throws "Player is accessed on the
// wrong thread" if touched off the player's construction thread. The
// barge-in pipeline reads [audioSessionId] from BargeInListener's
// Dispatchers.IO reader coroutine to attach AcousticEchoCanceler, so we
// can't expose the raw getter. Instead we cache the id from the
// main-thread Media3 callbacks below and serve the getter from this
// @Volatile field. (Fixes the legacy-TTS + barge-in crash where the
// first sentence played for ~2 syllables before the IO read threw.)
@Volatile private var cachedAudioSessionId: Int = 0
private val exoPlayer: ExoPlayer = exoPlayerFactory(context.applicationContext)
init {
// AnalyticsListener callbacks are delivered on the player's
// application (main) thread, so caching the id here is the
// authoritative, thread-correct way to track it as Media3 allocates
// and reallocates the underlying AudioTrack.
exoPlayer.addAnalyticsListener(object : AnalyticsListener {
override fun onAudioSessionIdChanged(
eventTime: AnalyticsListener.EventTime,
audioSessionId: Int,
) {
cachedAudioSessionId = audioSessionId
}
})
exoPlayer.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
_isPlaying.value = isPlaying
@@ -94,8 +119,16 @@ class VoicePlayer(
// actually begins — the audio session id is stable from
// player construction on Media3 1.x but some OEM pipelines
// don't allocate the track until playback starts.
if (isPlaying && !visualizerAttached) {
attachVisualizer(exoPlayer.audioSessionId)
if (isPlaying) {
// Belt-and-braces with the analytics listener above: this
// runs on the main thread too, so reading the getter here
// is safe and guarantees the cache is warm by the time
// playback is audible (and thus by the time barge-in
// starts its IO reader).
cachedAudioSessionId = exoPlayer.audioSessionId
if (!visualizerAttached) {
attachVisualizer(cachedAudioSessionId)
}
}
}
@@ -211,13 +244,20 @@ class VoicePlayer(
* poll this property briefly rather than assume it's hot-ready at
* [VoicePlayer] construction time.
*
* Exposed read-only. Internally the same id drives the Visualizer
* attach logic in [attachVisualizer]; B4 reads it via a provider
* lambda so the listener can re-check across the 1 s poll window
* without holding a stale reference.
* **Thread-safe.** Backed by [cachedAudioSessionId] rather than the raw
* `ExoPlayer.getAudioSessionId()` getter, because ExoPlayer is
* thread-confined and [BargeInListener] reads this from its
* `Dispatchers.IO` reader coroutine. Reading the raw getter off-main
* throws `IllegalStateException: Player is accessed on the wrong thread`.
* The cache is populated from main-thread Media3 callbacks (the
* [AnalyticsListener.onAudioSessionIdChanged] hook and `onIsPlayingChanged`).
*
* Exposed read-only. B4 reads it via a provider lambda so the listener
* can re-check across the 1 s poll window without holding a stale
* reference.
*/
val audioSessionId: Int
get() = exoPlayer.audioSessionId
get() = cachedAudioSessionId
/**
* Set the playback volume of the underlying ExoPlayer.
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
@@ -72,6 +73,7 @@ class VoicePlayerTest {
private lateinit var context: Context
private lateinit var exoPlayer: ExoPlayer
private var listener: Player.Listener? = null
private var analyticsListener: AnalyticsListener? = null
// Mirrors the real ExoPlayer's counter so the test's view and the
// VoicePlayer's view agree without having to drive every listener
@@ -99,6 +101,14 @@ class VoicePlayerTest {
listener = listenerSlot.captured
}
// Capture the AnalyticsListener too — VoicePlayer registers one to
// mirror the audio session id onto the main thread (the barge-in
// wrong-thread crash fix).
val analyticsSlot = slot<AnalyticsListener>()
every { exoPlayer.addAnalyticsListener(capture(analyticsSlot)) } answers {
analyticsListener = analyticsSlot.captured
}
// Queue + state inspection read from the fake counters so the
// VoicePlayer sees consistent values whether it reads them in
// play(), stop(), or from the listener callback.
@@ -139,6 +149,7 @@ class VoicePlayerTest {
fun tearDown() {
unmockkAll()
listener = null
analyticsListener = null
fakeMediaItemCount = 0
fakeIsPlaying = false
fakePlaybackState = Player.STATE_IDLE
@@ -227,4 +238,33 @@ class VoicePlayerTest {
verify { exoPlayer.volume = 1.0f }
assertEquals(1.0f, fakeVolume, 0.0001f)
}
@Test
fun `audioSessionId is served from the cached id, not the thread-confined getter`() {
// Regression: ExoPlayer is thread-confined, so reading
// exoPlayer.audioSessionId off the main thread (BargeInListener's
// Dispatchers.IO reader) throws "Player is accessed on the wrong
// thread" and crashed voice mode mid-playback. The getter must read
// a cached value instead — populated from main-thread callbacks.
val voicePlayer = VoicePlayer(context) { exoPlayer }
// Media3 reports a freshly-allocated session id on the main thread.
analyticsListener?.onAudioSessionIdChanged(mockk(relaxed = true), 42)
assertEquals(42, voicePlayer.audioSessionId)
// Reading the property again must not touch the raw ExoPlayer getter
// (that's the off-main call that throws). The only legitimate read of
// exoPlayer.audioSessionId happens inside onIsPlayingChanged on the
// main thread, which this test never triggers.
voicePlayer.audioSessionId
voicePlayer.audioSessionId
verify(exactly = 0) { exoPlayer.audioSessionId }
}
@Test
fun `audioSessionId defaults to zero before any session is allocated`() {
val voicePlayer = VoicePlayer(context) { exoPlayer }
assertEquals(0, voicePlayer.audioSessionId)
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
[versions]
appVersionName = "0.8.0"
appVersionCode = "10"
appVersionName = "0.8.1"
appVersionCode = "11"
agp = "8.13.2"
kotlin = "2.3.20"
compose-bom = "2026.03.01"
+1
View File
@@ -104,6 +104,7 @@ export default defineConfig({
text: 'Architecture',
items: [
{ text: 'Overview', link: '/architecture/' },
{ text: 'Relay Architecture Spec', link: '/architecture/relay-architecture-spec' },
{ text: 'Decisions', link: '/architecture/decisions' },
{ text: 'Security', link: '/architecture/security' },
{ text: 'Privacy', link: '/architecture/privacy' },
+2
View File
@@ -4,6 +4,8 @@
The app maintains two independent connection paths — direct HTTP/SSE for chat, persistent WSS for relay channels.
For a compact shareable reference covering connection paths, transport boundaries, pairing/session lifecycle, and operator controls, see the [Relay Architecture Spec](/architecture/relay-architecture-spec).
<HermesFlow diagram="architecture" height="260px" />
| Path | Protocol | Server | Purpose |
@@ -0,0 +1,70 @@
<script setup>
import { withBase } from 'vitepress'
</script>
# Relay Architecture Spec
This is the compact reference for the Hermes-Relay connection model, pairing/session lifecycle, transport boundaries, and operator-owned controls.
<div class="spec-image-wrap">
<a :href="withBase('/architecture-spec.png')" target="_blank" rel="noopener">
<img :src="withBase('/architecture-spec.png')" alt="Hermes-Relay architecture spec diagram showing connection paths, auth gates, transport modes, session lifecycle, enforced controls, operator responsibilities, and baseline configuration." />
</a>
</div>
<p class="spec-image-link">
<a :href="withBase('/architecture-spec.png')" target="_blank" rel="noopener">Open the full-size spec image</a>.
</p>
## Deployment boundary
Hermes-Relay is designed for operator-owned infrastructure. Remote access should use Tailscale, another VPN, or HTTPS/WSS termination controlled by the operator. Plain `ws://` is treated as a trusted LAN/VPN mode only.
## Connection paths
| Surface | Path | Auth model | Notes |
|---|---|---|---|
| Chat | Phone/desktop → Hermes API Server | Hermes API bearer / API server auth | Uses HTTP/SSE and does not require the relay path. |
| Voice | Phone/desktop → Relay voice routes | Relay session grant or Hermes API bearer | Non-loopback API-bearer voice requests require HTTPS unless the operator enables the temporary insecure dev toggle. |
| Bridge + terminal | Phone/desktop ↔ Relay Server | Relay pairing code → session token + grants | High-trust path for terminal, media, bridge, and device-control surfaces. |
| Management routes | Host-local only | Loopback caller | Pairing registration, minting, relay security toggles, and bridge status stay loopback-only. |
## Pairing / session lifecycle
1. **Mint** — the Hermes host creates a short-lived QR/code and embeds relay/API endpoint metadata.
2. **Exchange** — the client proves possession by sending the code in its first auth envelope over the relay connection.
3. **Token** — the relay consumes the code and issues an expiring bearer token with per-channel grants.
4. **Revoke** — paired devices can be inspected, extended, limited, or revoked from app/dashboard routes.
## Implemented controls
- Pairing codes are temporary and one-shot.
- Relay sessions use scoped bearer tokens with feature-specific grants and expiries.
- Terminal, bridge, voice, media, clipboard, and profile-write paths check active grants.
- Non-loopback API-bearer voice calls require HTTPS by default.
- Pairing and management routes are loopback-only.
- Bridge/device-control actions require user-visible Android toggles and OS-level permissions before acting.
## Operational constraints
- Relay tokens are bearer credentials; protect them like API keys.
- Bridge and terminal grants are high-trust capabilities.
- Plain WebSocket is for trusted LAN/VPN only.
- Public exposure should sit behind TLS and firewall or VPN controls.
- Unknown or stale paired devices should be revoked.
<style scoped>
.spec-image-wrap {
margin: 1.5rem 0 2rem;
border: 1px solid var(--vp-c-border);
border-radius: 18px;
overflow: hidden;
background: #020617;
}
.spec-image-wrap img {
display: block;
width: 100%;
height: auto;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 632 KiB