Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed9dafe571 | ||
|
|
851dfc7f25 | ||
|
|
b0df2c83f5 | ||
|
|
a8a4bc7514 | ||
|
|
65db2e60d4 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user