Compare commits
25
Commits
+25
-2
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Added
|
||||
|
||||
- **Connection security indicator.** The chat status chip, the connection card, and the route picker now show at a glance whether your connection is encrypted — 🔒 **Encrypted · TLS**, 🛡️ **Encrypted · Tailscale** (both secure), 🛡️ **Mixed routes**, or ⚠️ **Not encrypted** — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale/WireGuard route is now correctly shown as encrypted rather than implied insecure. Adds a new "Is my connection secure?" docs page explaining the difference between TLS and overlay (WireGuard) encryption.
|
||||
- **Desktop CLI: `hermes-relay audit`.** Shows what the remote agent has actually run on this machine through the desktop tools — tool, status, and a short detail per call — read from a local log, no network or auth. Answers "what did the agent just do?" at a glance.
|
||||
- **Desktop CLI: `hermes-relay relay`.** Inspect the relay server itself: `relay info` (version, uptime, sessions — on the relay host), `relay security` (runtime auth toggles), and `relay context` (audit the system-prompt context the relay injects into the agent, which works from a remote machine with your session).
|
||||
- **Desktop CLI: background daemon.** `hermes-relay daemon start` runs the headless tool router in the background (no console window, survives closing the terminal), with `daemon stop` and `daemon status` to manage it. `daemon status` reports state, uptime, relay, and advertised-tool count; bare `daemon` still runs in the foreground. Logs go to `~/.hermes/daemon.log`.
|
||||
@@ -20,6 +21,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Desktop CLI: smoother pairing.** The multi-endpoint probe shows per-endpoint progress and latency; a near-expiry session warns before it fails and prints the exact re-pair command; and a bare `ws://host` (no port) defaults to `:8767`.
|
||||
- **Desktop CLI: voice + consent transparency.** `voice` now surfaces enhanced-voice capabilities (Gemini tone tags / persona, xAI speech tags); the desktop-tool consent prompt is clear that it persists per relay and points at `hermes-relay audit`; and computer-use's observe → grant → act flow is documented in `--help`.
|
||||
|
||||
## [1.2.3] - 2026-06-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Crash on connect over TLS / Tailscale.** Connecting to a server over an encrypted link (Tailscale Serve or public HTTPS) could hard-close the app with `NetworkOnMainThreadException`. Tearing down an HTTP client closed live SSL sockets on the main thread, and a TLS socket close performs a network write — which Android forbids on the main thread. Client shutdown now always closes sockets off the main thread, so connecting over a secured link no longer crashes. (#118, #124; likely the v1.1.0 / Tailscale crash in #70)
|
||||
|
||||
## [1.2.2] - 2026-06-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Diagnostics: status timeline.** Diagnostics now opens full-screen and leads with a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a clear pass / warning / fail state and, when something's wrong, the reason why; tap a failing check for full detail. The recent-activity log stays below it.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Connections wording simplified.** The default connection is now just "Hermes" (previously "Vanilla" / "Standard Hermes"), and the optional power features are labelled "Relay" / "Relay plugin", across the connection setup, switcher, voice, and permissions screens.
|
||||
- **Clean chat mode shows more text.** The distraction-free chat view gives its text a noticeably taller, scrollable area instead of capping it near a third of the screen.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Deleting a session on a non-default profile now sticks.** Removing a chat while a non-default agent profile was active could leave it on the server, so it reappeared after the list refreshed; the delete is now scoped to the active profile.
|
||||
- **Session drawer opens on the right profile from a cold start.** When launching with a non-default profile selected, the session list could briefly show the default profile's chats and then snap to the correct ones; it now waits for the profile to resolve and loads the right list directly.
|
||||
|
||||
## [1.2.1] - 2026-06-21
|
||||
|
||||
### Added
|
||||
@@ -346,11 +369,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
- **Pre-release hardening: uninstall, doctor, first-run prompts, version-aware install.** Four parallel workstreams that close the "feels like a dev preview" gap before tagging `desktop-v0.3.0-alpha.1`. (1) **Uninstall scripts** — new `desktop/scripts/uninstall.{sh,ps1}` matching install one-liners, 3-tier: default `--binary-only` (removes binary + PATH entry, preserves `~/.hermes/remote-sessions.json`), `--purge` (also wipes the shared session store with a loud cross-surface warning about Ink TUI + Android tooling dependencies), `--service` (stub for when daemon service installers ship — prints canonical systemd/launchd/sc.exe paths without acting). iex-pipe safety: Windows falls back to `HERMES_RELAY_UNINSTALL_{PURGE,SERVICE}` env vars since `$args` drops through `irm | iex`. Shell rc files deliberately untouched (mirrors install.sh philosophy). (2) **`hermes-relay doctor` subcommand** — local-only diagnostic report (225 lines, `src/commands/doctor.ts`); human format uses `!!` prefix for warnings + hint line at bottom, `--json` for support-paste / scripts. Fields: version / binary_path / install_dir / on_path / sessions file + size + count + summaries (no tokens — total omission, not even prefix) / daemon detection (stat of canonical service unit file paths) / platform + node version. Case-insensitive PATH comparison on Windows. (3) **Interactive first-run fallback** — new `src/relayUrlPrompt.ts` (~180 lines) with `promptForRelayUrl()` (readline on stderr, `^wss?:\/\/\S+$` validation, 3 retries) and `resolveFirstRunUrl()` (auto-picks single stored session, numbered picker for multiple, first-run banner for zero). Wired into `connectAndAuth` in `shell.ts` / `chat.ts` / `tools.ts` and `resolvePairTarget` in `pair.ts`, replacing the hard `No relay URL` error. Fresh-install UX: bare `hermes-relay` now prints `Welcome to hermes-relay. No stored sessions yet — let's pair with a Server.` → URL prompt → pairing code prompt → drops into shell. `--non-interactive` still fails fast. Daemon command deliberately untouched — headless binaries must never prompt; fails closed on missing credentials/consent as before. (4) **Version-aware install** — `install.{sh,ps1}` now read `$target --version` before download and print one of `upgrading X → Y`, `reinstalling X`, `will replace (could not read version)`, or `installing fresh` (no prior install); post-install readback re-invokes the new binary to confirm. Pinned-version mismatches (`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1`) print a non-fatal WARN rather than failing (pre-release version-name drift is expected). 5s timeout on the version call (where `timeout(1)` available); all diagnostic failures fall through to the "could not read version" path. Cross-version normalizer strips `desktop-v` / `v` prefix + `-alpha.N` / `-beta.N` / `-rc.N` suffix for matching. All structural flow (SHA256 verify, tmp cleanup, PATH injection, quarantine note) preserved additively. Type-check + build green; live smoke: `doctor` both modes, `daemon` fails-closed without credentials, help text includes all new surfaces.
|
||||
|
||||
- **`hermes-relay daemon` — headless WSS + tool router, lifts the "tools only work while a shell is open" ceiling.** New `desktop/src/commands/daemon.ts` subcommand that opens a persistent relay connection and attaches `DesktopToolRouter` without a TTY. The agent can now reach the user's machine any time of day — first step toward "feels-local" parity. Fails closed on missing credentials (no stored session + no `--token` → exits 1) and on missing consent (no `toolsConsented: true` on the stored record → exits 1 unless `--allow-tools` is passed alongside an explicit `--token`); a headless binary must never be the thing that first grants tool access. Inherits `RelayTransport`'s reconnect state machine as-is — exp backoff 1s → 30s (5min on 429), reconnect listeners persistent across close/reconnect cycles because `channelListeners` is a Map on the transport (not wiped on socket close), so the router's `attach()` fires exactly once. Structured logging defaults to JSON-line on stderr (parseable by journald / log shippers / jq), auto-switches to human-readable when stderr is a TTY, or force either with `--log-json` / `--log-human`. Lifecycle events: `starting` → `authed` (includes `server_version`, `transport`) → `ready` (with `advertised_tools` list) → `reconnecting` (attempt + delay_ms) / `reconnected` → `shutdown` on SIGTERM/SIGINT/SIGHUP → `transport_exited` when the transport exhausts reconnects (exits 1 so the service manager restarts fresh). Live smoke against `ws://172.16.24.250:8767`: `starting` → `authed` (server 0.6.0) → `ready` (5 tools advertised) in ~120ms. New BOOLEAN_FLAGS entries: `log-human`, `log-json`, `allow-tools`. Service installers for Windows `sc.exe` / systemd user unit / macOS launchd plist are the obvious follow-up; the daemon binary is runnable standalone today via `hermes-relay daemon --remote <url>`.
|
||||
- **`hermes-relay daemon` — headless WSS + tool router, lifts the "tools only work while a shell is open" ceiling.** New `desktop/src/commands/daemon.ts` subcommand that opens a persistent relay connection and attaches `DesktopToolRouter` without a TTY. The agent can now reach the user's machine any time of day — first step toward "feels-local" parity. Fails closed on missing credentials (no stored session + no `--token` → exits 1) and on missing consent (no `toolsConsented: true` on the stored record → exits 1 unless `--allow-tools` is passed alongside an explicit `--token`); a headless binary must never be the thing that first grants tool access. Inherits `RelayTransport`'s reconnect state machine as-is — exp backoff 1s → 30s (5min on 429), reconnect listeners persistent across close/reconnect cycles because `channelListeners` is a Map on the transport (not wiped on socket close), so the router's `attach()` fires exactly once. Structured logging defaults to JSON-line on stderr (parseable by journald / log shippers / jq), auto-switches to human-readable when stderr is a TTY, or force either with `--log-json` / `--log-human`. Lifecycle events: `starting` → `authed` (includes `server_version`, `transport`) → `ready` (with `advertised_tools` list) → `reconnecting` (attempt + delay_ms) / `reconnected` → `shutdown` on SIGTERM/SIGINT/SIGHUP → `transport_exited` when the transport exhausts reconnects (exits 1 so the service manager restarts fresh). Live smoke against `ws://192.168.1.100:8767`: `starting` → `authed` (server 0.6.0) → `ready` (5 tools advertised) in ~120ms. New BOOLEAN_FLAGS entries: `log-human`, `log-json`, `allow-tools`. Service installers for Windows `sc.exe` / systemd user unit / macOS launchd plist are the obvious follow-up; the daemon binary is runnable standalone today via `hermes-relay daemon --remote <url>`.
|
||||
|
||||
- **Desktop CLI v0.2 — PTY shell, local tool routing, multi-endpoint pairing, reconnect + TOFU, devices, contextual banner.** The `@hermes-relay/cli` package at `desktop/` grew from a chat-only scripting surface into a full Hermes-experience thin client. Bare `hermes-relay` now drops into `shell` mode (interactive PTY pipe through the existing relay `terminal` channel → `tmux new-session -A` + post-attach `exec hermes` → the full local `hermes` banner/skin/session id verbatim, zero server changes). `Ctrl+A .` detaches preserving tmux; `Ctrl+A k` destroys it. New `devices` subcommand drives the relay's `GET/DELETE/PATCH /sessions` HTTP endpoints for listing, revoking, and extending server-side paired-device tokens. Status now surfaces `grants:` (per-channel expiry) and `expires:` (session TTL) pulled from the `auth.ok` handshake the transport already received — `RemoteSessionRecord` gained `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented` (additive, back-compat preserved via a `SaveSessionOptions | string | null` overload on `saveSession`). Contextual connect banner (`Connected via LAN (plain) — server 0.6.0`) replaces the flat `Connected (server X)` line across `chat` + `shell`. Multi-endpoint pairing (ADR 24): `--pair-qr <payload>` / `HERMES_RELAY_PAIR_QR` accepts a full v3 QR payload (compact JSON or base64), decodes the `endpoints[]` array, probes each candidate with strict-priority-within-tier racing (`Promise.any` + `AbortSignal.any`, 4 s per-candidate timeout, 60 s reachability cache), and auto-selects the first reachable — role propagates into the banner + stored record. Reconnect-on-drop: `RelayTransport` gained a `ReconnectState` machine (`idle|connecting|connected|reconnecting`), exponential backoff (1 s → 30 s, 5 min on 429), `reconnectGate` re-checked both at schedule time and post-backoff (matches Android's mid-sleep purge-race lesson), `'reconnecting'` + `'reconnected'` events, and bufferedEvents-cleared-on-reconnect. TOFU cert pinning: TLS probe runs before the WebSocket opens on `wss://`, extracts peer-cert SPKI sha256 (`sha256/<base64>`, OkHttp-compatible), compares against the stored pin or captures it first-time; mismatches error out with a human-readable "re-pair to reset" pointer. Client-side tool routing (Phase B): new `desktop` relay channel on the server (`plugin/relay/channels/desktop.py` + `plugin/tools/desktop_tool.py` registering `desktop_read_file` / `desktop_write_file` / `desktop_terminal` / `desktop_search_files` / `desktop_patch`) forwards tool calls from Hermes to the connected Node CLI; client-side `DesktopToolRouter` dispatches to in-process handlers (`fs`, `terminal`, `search`) under a 30 s AbortController, 30 s heartbeat advertising the tool names. Gated behind a one-time per-URL consent prompt (`toolsConsented` on the session record) + `--no-tools` kill-switch; non-TTY stdin fails closed. New files on the client: `src/banner.ts`, `src/endpoint.ts`, `src/pairingQr.ts`, `src/certPin.ts`, `src/commands/devices.ts`, `src/tools/router.ts`, `src/tools/consent.ts`, `src/tools/handlers/{fs,terminal,search}.ts`. New files on the server: `plugin/relay/channels/desktop.py`, `plugin/tools/desktop_tool.py`, `docs/relay-protocol.md §3.5`. Still zero runtime deps on the client (Node ≥21 global `WebSocket` + `fetch` + `tls.connect` + `node:crypto` X509Certificate + `AbortSignal.any`). Build clean; live smoke passed for `status` / `tools` / `devices`; interactive `shell` + tool-call smoke pending user walk-through. Delivered as four parallel implementation agents (multi-endpoint, reconnect+TOFU, server-side desktop, client-side tool handlers) + one synthesis-and-integration pass; the `connectAndAuth → {relay, url, endpointRole}` return-shape refactor in `chat.ts` / `shell.ts` / `tools.ts` unifies how `--pair-qr`'s winning-endpoint URL overrides `--remote` across every subcommand.
|
||||
|
||||
- **Desktop thin-client CLI (`@hermes-relay/cli`) v0.1 under `desktop/`.** Node ≥21 package — installable via `npm install -g @hermes-relay/cli`, `npx @hermes-relay/cli`, or the new `scripts/install.sh` / `install.ps1` curl+iwr one-liners. One `hermes-relay` binary with four subcommands: `chat` (REPL + one-shot + piped-stdin, default), `pair` (one-time handshake → persists session token), `status` (local read of `~/.hermes/remote-sessions.json`), `tools` (`tools.list` RPC → enabled/available toolsets on the server). Credential precedence matches the Ink TUI exactly: `--token` → `HERMES_RELAY_TOKEN` → `--code` → `HERMES_RELAY_CODE` → stored session → interactive readline prompt. Reuses the **same** `~/.hermes/remote-sessions.json` store as the TUI, so a user paired via either surface sees the other work with no re-pair. Zero server changes: the CLI consumes the existing relay `tui` WSS channel + `tui_gateway` subprocess events (`message.delta`, `tool.start/complete`, `thinking.delta`, `status.update`, `error`, `approval.request`, …) and renders them as plain lines to stdout, with decorated tool arrows on stderr. Flags: `--remote <url>`, `--code <CODE>`, `--token <TOKEN>`, `--session <id>`, `--json` (event-per-line for `jq`), `--verbose`, `--quiet`, `--no-color`, `--non-interactive`, `--reveal-tokens` (opt-in full-token output on `status --json` — default redacts). Transport, gateway types, session storage, graceful-exit, and rpc helpers are **vendored verbatim** from `hermes-agent-tui-smoke/ui-tui/src/` (feat/tui-transport-pluggable) with a header note; the CLI and TUI stay in lockstep on the envelope protocol (docs/relay-protocol.md §3.7) until the shared surface can be lifted into a `@hermes-relay/core` package post-stabilization. SIGINT during a turn calls `session.interrupt` via a per-turn `{ promise, cancel }` handle — the REPL's cancellation state lives and dies with the turn so a late-arriving `error` event for a cancelled turn can't be misread by the next turn's handler. Smoke-tested end-to-end against `ws://172.16.24.250:8767` (hermes-relay 0.6.0, hermes-agent 0.10.0): connect/auth/session.create/prompt.submit/tools.list/--json/piped-stdin all clean. Not yet wired: interactive approval/clarify/sudo/secret request response (renderer logs a warning; out of scope for v0.1). Upstream PR candidate once the sibling Ink TUI stabilizes — see `desktop/README.md` and vault `Desktop Client.md` for the broader thin-client roadmap.
|
||||
- **Desktop thin-client CLI (`@hermes-relay/cli`) v0.1 under `desktop/`.** Node ≥21 package — installable via `npm install -g @hermes-relay/cli`, `npx @hermes-relay/cli`, or the new `scripts/install.sh` / `install.ps1` curl+iwr one-liners. One `hermes-relay` binary with four subcommands: `chat` (REPL + one-shot + piped-stdin, default), `pair` (one-time handshake → persists session token), `status` (local read of `~/.hermes/remote-sessions.json`), `tools` (`tools.list` RPC → enabled/available toolsets on the server). Credential precedence matches the Ink TUI exactly: `--token` → `HERMES_RELAY_TOKEN` → `--code` → `HERMES_RELAY_CODE` → stored session → interactive readline prompt. Reuses the **same** `~/.hermes/remote-sessions.json` store as the TUI, so a user paired via either surface sees the other work with no re-pair. Zero server changes: the CLI consumes the existing relay `tui` WSS channel + `tui_gateway` subprocess events (`message.delta`, `tool.start/complete`, `thinking.delta`, `status.update`, `error`, `approval.request`, …) and renders them as plain lines to stdout, with decorated tool arrows on stderr. Flags: `--remote <url>`, `--code <CODE>`, `--token <TOKEN>`, `--session <id>`, `--json` (event-per-line for `jq`), `--verbose`, `--quiet`, `--no-color`, `--non-interactive`, `--reveal-tokens` (opt-in full-token output on `status --json` — default redacts). Transport, gateway types, session storage, graceful-exit, and rpc helpers are **vendored verbatim** from `hermes-agent-tui-smoke/ui-tui/src/` (feat/tui-transport-pluggable) with a header note; the CLI and TUI stay in lockstep on the envelope protocol (docs/relay-protocol.md §3.7) until the shared surface can be lifted into a `@hermes-relay/core` package post-stabilization. SIGINT during a turn calls `session.interrupt` via a per-turn `{ promise, cancel }` handle — the REPL's cancellation state lives and dies with the turn so a late-arriving `error` event for a cancelled turn can't be misread by the next turn's handler. Smoke-tested end-to-end against `ws://192.168.1.100:8767` (hermes-relay 0.6.0, hermes-agent 0.10.0): connect/auth/session.create/prompt.submit/tools.list/--json/piped-stdin all clean. Not yet wired: interactive approval/clarify/sudo/secret request response (renderer logs a warning; out of scope for v0.1). Upstream PR candidate once the sibling Ink TUI stabilizes — see `desktop/README.md` and vault `Desktop Client.md` for the broader thin-client roadmap.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -1,6 +1,47 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-06-21 — Released android-v1.2.1
|
||||
## 2026-06-23 — Fix NetworkOnMainThreadException crash on TLS connect
|
||||
|
||||
**Why.** Two external bug reports (#118, #124) and the later comment on #70 reported the app hard-closing on connect over an encrypted link (Tailscale Serve / public HTTPS). The auto-captured traces were identical: `android.os.NetworkOnMainThreadException` from `okhttp3.ConnectionPool.evictAll()`, with a suppressed `Dispatchers.Main.immediate [Cancelling]` frame — i.e. a `viewModelScope` coroutine.
|
||||
|
||||
**Root cause.** `HermesApiClient.shutdown()`, `DashboardApiClient.shutdown()`, and `ConnectionManager.shutdown()` each call `connectionPool.evictAll()` inline. `evictAll()` closes pooled sockets synchronously; for a live `https`/`wss` keep-alive connection a TLS close drains a close-notify through `SSLOutputStream` — a real network write StrictMode forbids on the main thread. Several call sites reach `shutdown()` from a `viewModelScope` (`Dispatchers.Main.immediate`) coroutine: `probeStandardVoice()`'s `finally { client.shutdown() }` fires on every connect/voice probe, and `onCleared()` called `connectionManager.shutdown()` directly on the main thread. The off-main handling existed only as scattered per-call-site `withContext(Dispatchers.IO)` / background-`Thread` wrappers, so the unwrapped paths still crashed. TLS-only because a plaintext socket close writes nothing — matching every report being on Tailscale/public TLS.
|
||||
|
||||
**Fix.** Pushed the guard into the leaf. New `network/NetworkShutdown.kt#shutdownOffMainThread(name, block)` runs the executor-shutdown + `evictAll()` on a short-lived daemon thread when called from the main thread, and inline otherwise (preserving the blocking `awaitTermination` semantics for callers already on IO). Wrapped all three `shutdown()` bodies with it, so every call site is safe regardless of dispatcher. Simplified `ConnectionViewModel.onCleared()` — its now-redundant manual `Thread` wrappers were removed and `connectionManager.shutdown()` is no longer an unguarded main-thread `evictAll()`.
|
||||
|
||||
**Verification.** New Robolectric `NetworkShutdownTest` (2 cases) asserts the teardown runs off the main thread when invoked from the main looper, and inline when invoked off it. `./gradlew :app:testSideloadDebugUnitTest --tests NetworkShutdownTest` green (compiles the full module + both cases pass). On-device confirmation over a real Tailscale/TLS connection pending a Studio build.
|
||||
|
||||
## 2026-06-22 — Released android-v1.2.2
|
||||
|
||||
Cut Android **1.2.2** (appVersionName 1.2.2 / appVersionCode 16) — "Multi-profile polish". The version bump + release docs were already on `dev`; the cut first integrated `origin/dev`, which had advanced to **compileSdk 37** (`206d182`) and typed `stream.event` passthrough (PR #120) — dropping the temporary 1.2.2-prep `markdown-renderer 0.41.0` / `lifecycle 2.10.0` pins (a compileSdk-36 workaround) for compileSdk 37 + the `0.42.0` / `2.11.0` deps. `dev` CI (Android build + tests on compileSdk 37) green; release PR #122 (`dev` → `main`, `--no-ff`, merge `984d9a2`) merged on green Required-checks + claude-review; `android-v1.2.2` tagged from the `main` tip triggered `release-android.yml` → signed APK/AAB (googlePlay + sideload) + `SHA256SUMS.txt` → GitHub Release **Hermes-Relay-Android v1.2.2** (published, not draft). Headline 1.2.2: session-delete persists on non-default profiles, cold-start profile isolation for the session drawer, full-screen Diagnostics status timeline, "Hermes"/"Relay" connection wording, and the clean-chat layout + scrollable history; also ships the typed `stream.event` relay passthrough (first slice) integrated from `dev`. Post-cut: `main` back-merged into `dev` (fast-forward) so they stay aligned. Follow-up: CLAUDE.md still says "Compile SDK 36" — update to 37 to match the build.
|
||||
|
||||
## 2026-06-22 — Outstanding-TODO batch (orchestration): four User-Added fixes
|
||||
|
||||
**Why.** Four open User-Added TODO items, resolved in one 4-worker orchestration pass with disjoint file ownership and coordinator-serialized commits (workers edited only; the coordinator committed each task's files by pathspec to avoid the shared-index race). A read-only Explore pass mapped each task to its files first, surfacing the two collision hubs (`ChatScreen.kt`, `RelayApp.kt`) so ownership could be partitioned to keep all four file sets disjoint. All changes are client-side Kotlin. **Unbuilt at time of writing — pending Studio build + `./gradlew lint`.**
|
||||
|
||||
- **Session delete on a non-default profile now persists (`6552566`).** Root cause: a non-default Hermes profile keeps its sessions in that profile's own `state.db`, but `ChatViewModel.deleteSession()` issued the unscoped api_server `DELETE /api/sessions/{id}` (shared DB, no profile) and never re-fetched — so the row survived and the next profile-scoped list resurrected it. Fix mirrors the read path onto the write path: `DashboardApiClient.deleteSession(id, profile)` (reusing the `deleteCronJob` plumbing — `deleteJsonObject`+`pathSegment`+`profileQuery`), `ConnectionViewModel.deleteProfileScopedSession()` (twin of `listProfileScopedSessions`), a `ChatViewModel.profileSessionDeleter` hook wired in `RelayApp` beside `setProfileSessionLister`, and a `refreshSessions()` after a successful delete. Gateway deletes route through the dashboard surface; off-gateway (one shared DB) the plain delete is unchanged. `HermesApiClient` left untouched — the api_server has no profile concept.
|
||||
- **Diagnostics → full-screen status-check timeline; analytics polish (`c3098a9`).** Replaced the Diagnostics modal bottom sheet with a dedicated `DiagnosticsScreen` behind a new `Screen.Diagnostics` nav route. It leads with a vertical status-check timeline — Network, API server, server capabilities, chat transport, pairing/auth, relay, voice — each a green/amber/red/gray dot on a connecting rail with an inline failure reason; a check backed by a logged error is tappable into the existing `DiagnosticDetailDialog`. Checks derive **read-only** from existing `ConnectionViewModel` flows + the recent `DiagnosticsLog` via a pure, testable `buildStatusChecks()` (no new probing — honest snapshot, first-class `Unknown`). New `StatusCheck`/`CheckStatus` models in `DiagnosticsLog.kt`, a reusable `StatusCheckTimeline` composable in `TimelineView.kt`; the recent-activity log panel stays below. Analytics: `AnalyticsScreen`/`StatsForNerds` visual hierarchy tidied (de-duped the header, section subtitle, cleaner separators) with no data/behavior change.
|
||||
- **Connections reframe: "Vanilla/Standard Hermes" → "Hermes" (`c9fa8f7`).** 28 user-facing display strings across 10 connection/voice/permissions files; "Hermes-Relay plugin" → "Relay plugin" where it reads naturally. Display copy only — `StandardVoiceAvailability`, `VoiceAudioRoute.Standard("standard")` (enum + storage value), `RelayUiState`, and all when-branch identifiers left intact.
|
||||
- **Clean-chat: taller scrollable text viewport (`1dca285`).** Replaced the fragile `screenHeightDp*0.34f` height cap on the clean-mode text flow with a weight split (centered sphere `weight(1f)` / flow `weight(1.1f)` ≈ 52% of the vertical slack, up from ~34%); kept the `min=96.dp` floor, internal scroll, top-fade, and a11y mirror paths; dropped the now-dead `LocalConfiguration` import.
|
||||
- **Method.** Coordinator mapped files (4 parallel Explore agents) → partitioned disjoint ownership (A: `ChatViewModel`/`HermesApiClient`/`DashboardApiClient`/`ConnectionViewModel`; B: 9 connection/voice files + `ChatScreen.kt` 2 strings; C: `AgentTextFlow.kt`; D: analytics/diagnostics + new screen + `SettingsScreen`/`RelayApp`) → file-briefed 4 Claude workers in the active worktree (Orca `--inject` no-ops here) → serialized pathspec commits as each `worker_done` landed. The session-delete fix's one `RelayApp` wiring line was held and applied by the coordinator after the diagnostics worker's `RelayApp` route changes committed, so both edits to that hub landed as clean, separate commits.
|
||||
|
||||
**Verification.** Symbol-existence verified by grep before committing the new `DiagnosticsScreen` (the highest compile risk, since workers can't run gradle): all 11 referenced `ConnectionViewModel` flows, `HealthStatus`/`ConnectivityObserver.Status`/`AuthState`/`DiagnosticCategory` enum shapes, `ServerCapabilities` members, and the `DiagnosticsLogPanel`/`DiagnosticDetailDialog`/`StatusCheckTimeline` signatures resolve. Each worker diff was reviewed before commit. **Not built or linted** — Studio build + `./gradlew lint` + on-device checks pending (see TODO.md "Orchestration batch (2026-06-22)").
|
||||
|
||||
**Follow-up (same session) — cold-start profile-isolation race (`889273a`).** A user-reported sibling of the session-delete bug: on cold start the session drawer (and the restored session context) briefly loaded the SERVER-DEFAULT profile's sessions, then visibly snapped to the persisted profile. Root cause: the `activeConnectionId` observer stamps the persisted profile name pending, calls `resolvePendingProfileFrom(agentProfiles.value)` (empty at that point), then `rebuildChatApiClient()` — so `chatClientReady` flips true and the `RelayApp` `LaunchedEffect` fires the first `refreshSessions()` with a null (server-default) profile *before* the per-connection profile list arrives to resolve the selection; the list lands a tick later, re-resolves, and re-fetches correctly (the "self-reload"). Fix: new `ProfileController.selectionSettled` StateFlow — true once the selection resolved, OR no non-default profile is pending, OR the profile list has arrived (resolution attempted, so a genuinely-missing profile falls back to default rather than gating forever) — exposed via `ConnectionViewModel.profileSelectionSettled` and added as a key + gate to the cold-start effect. While unsettled the first load waits on a 2.5s backstop; the effect re-fires the instant the profile resolves, cancelling the wait so only the correct profile-scoped load lands, and the backstop prevents a permanently-empty drawer if the list never arrives. Same effect also defers the per-profile session-context/transcript restore. Other profile-scoped surfaces (voice prefs, display alias, profile icon) read the live `selectedProfile` and self-correct on resolution without a visible content-flash; gating them on `selectionSettled` is noted as a follow-up. Unbuilt — verify the cold-start drawer on device.
|
||||
|
||||
## 2026-06-22 — Typed stream.event Relay passthrough first slice
|
||||
|
||||
**Why.** AXI-75 asks Relay/native clients to stop flattening Hermes SSE into assistant text and preserve runtime structure for native UI cards/timelines.
|
||||
|
||||
- **Protocol + fixture.** `docs/relay-protocol.md` now defines auth capability negotiation (`supports.typed_stream_events` + `event_schema_version: 1`), the versioned `chat`/`stream.event` envelope, stable event families, ordering/de-dupe semantics, payload safety, fallback behavior, and native rendering guidance. Added `docs/fixtures/typed-stream-v1.jsonl` as a golden tool-using stream.
|
||||
- **Relay server.** `plugin/relay/server.py` records per-WebSocket client capabilities during `system/auth` and passes them to `ChatHandler`. `plugin/relay/channels/chat.py` now forwards Hermes/API-server SSE as ordered `stream.event` payloads for capable clients, emits final `done`, redacts secret-shaped keys, truncates large result fields, and keeps legacy `chat.delta`/`chat.tool.*`/`chat.completed` fallback for old clients.
|
||||
- **Native clients.** Android and Desktop auth envelopes advertise typed-stream support. Android gained `RelayStreamEventEnvelope` plus `ChatHandler.applyRelayStreamEvent()` that maps typed events to existing native assistant text, thinking/progress, tool-card, artifact/memory/skill chip, error, and completion state.
|
||||
- **Verification.** `PYTHONPATH=$PWD python -m unittest discover -s plugin/tests -p test_chat_typed_stream.py` green (typed ordering/final done/redaction + legacy fallback). `python -m py_compile plugin/relay/channels/chat.py plugin/relay/server.py plugin/tests/test_chat_typed_stream.py` green. `desktop/npm ci` then `npm run type-check` green. Android unit task was attempted with `ANDROID_HOME=/home/bailey/Android/Sdk ./gradlew :app:testSideloadDebugUnitTest --tests ...`; it is blocked before Kotlin compile by the current dependency/SDK mismatch (AAR metadata requires compileSdk 37; installed SDK only has android-36). Follow-up commits bump app/relay-core/relay-ui/quest compileSdk to 37 to satisfy current AndroidX/Markdown AAR metadata in CI without changing targetSdk.
|
||||
|
||||
## 2026-06-22 — Released plugin-v1.2.1
|
||||
|
||||
Cut the Plugin 1.2.1 release — a Realtime Agent reliability patch. Both fixes were already on `dev`: the `session_not_found` brokered-handoff fix (`f6b965a`) and the realtime voice heartbeat-during-long-runs fix (`d1820fb`); 1.2.1 only adds the version bump and release packaging. Release-prep bumped the six plugin version sources via `scripts/bump-plugin-version.sh` (sync check green), folded the relay `session_not_found` fix into the existing `[1.2.1]` `CHANGELOG.md` line (the Desktop-CLI entries stay under `[Unreleased]` for their own `cli-v*` cut), and rewrote `PLUGIN_RELEASE_NOTES.md` as a Fixed-only release body.
|
||||
|
||||
- **Release.** `dev` had drifted behind `main` (12 Dependabot bumps merged straight to `main` + 3 prior `dev`→`main` release-merge commits never back-merged), so release PR #119 was `BEHIND`; `gh pr update-branch` merged `main` into `dev` (conflict-free — no overlap with the version/CHANGELOG files). Only `Required checks` + `claude-review` gate `main` (the path-optimized sentinel pattern); both green, with the plugin-relevant jobs (focused plugin tests, dashboard build, Python syntax) also green on the head. Merged `--no-ff` (merge `41037a3`); `plugin-v1.2.1` tagged from the `main` tip triggered `release-plugin.yml` → validate-metadata → wheel + sdist + `SHA256SUMS.txt` → GitHub Release **Hermes-Relay-Plugin v1.2.1**.
|
||||
|
||||
Cut the Android 1.2.1 release. Version source (`appVersionName 1.2.1` / `appVersionCode 15`) was already on `dev`; release-prep promoted `CHANGELOG.md` `[Unreleased]` → `[1.2.1]` (**Android-only** — the Desktop-CLI entries and the relay `session_not_found` fix stay under `[Unreleased]` for their own `cli-v*`/`plugin-v*` cuts) and rewrote `RELEASE_NOTES.md`, in-app `whats_new.txt`, the Play release notes, and the Play listing copy, all scrubbed for public distribution. Release PR #102 (`dev` → `main`, `--no-ff`) auto-merged on green CI (merge `39cafc2`); `android-v1.2.1` tagged from the `main` tip triggers `release-android.yml` (validate → signed APK/AAB + checksums + GitHub Release; Play Production *draft* when the service-account secret is set, operator clicks Start rollout). Headline 1.2.1 changes: profile lock, in-app changelog, diagnostics detail + Copy/Share/Create-issue, a dismissable update-available nudge, plus voice/realtime fixes (override applies in Auto, realtime Stop halts playback, steadier hold-to-talk, readable overlay, faster connection-overlay dismiss) and a debug-only Developer-options test harness. `RELEASE.md` §2 gained a per-surface CHANGELOG-split clarification.
|
||||
|
||||
@@ -563,7 +604,7 @@ Tests: +4 resolver outcome tests, +2 ConnectionManager `probeAndReconnectNow` pu
|
||||
|
||||
**user-docs cockpit rechrome + content refresh (same day).** The docs site still wore the pre-refresh "Nothing-inspired" chrome (OLED `#000`, neutral grays, `#7C3AED` purple) while the app shipped the relay cockpit palette two days earlier. Rechromed `user-docs/.vitepress` to mirror `RelayRefresh.kt`: dark mode is now navy-black `#08090D` with navy panels (`#121426`/`#191B31`), warm-white ink `#F7F6F0`, alpha-based warm-white hairlines (the `Line`/`LineStrong` trick), Relay periwinkle `#AEBFFF` for links/active text vs ElectricMuted `#4F5BD5` for fills (same glare lesson as the app), status colors from the app's Green/Amber/Danger, a 42px-grid + 10px Relay-dot lattice on the home surface mirroring `relayGridTexture()`, and light mode moved to warm paper `#F7F3EA`. Hardcoded old-palette colors swept from HermesFlow/HermesFlowNode (edges, nodes — diagrams stay dark in both modes like instrument panels), HeroDemo (navy bezel + periwinkle glow), ExperimentalBadge (app Amber), FeatureMatrix (sideload tint). Content pass from a full staleness audit: four complete pages were unreachable from the sidebar (`features/voice`, `features/voice-intents`, `features/phone-control-tools`, `reference/relay-server`) plus `architecture/flavor-differences` linked from nowhere — all five added to `config.mts`; `guide/index.md` version heading bumped 0.8.0→0.8.1; `desktop/installation.md` example pins bumped alpha.14→alpha.18. Build verified: compiled CSS/JS contain the new palette and zero old-palette hex values. Deferred: demo video + the 5 dashboard screenshot TODOs in `features/dashboard.md` (chat_demo.mp4 and the poster also predate the cockpit refresh and should be re-captured). Feedback round (live design review on the dev server): dark brand accent shifted from Relay periwinkle `#AEBFFF` → electric indigo `#6E7CFF` ("too light, not our app blue" — periwinkle survives only in the dot texture); SphereMark gained a radial occlusion halo so the home dot-grid fades behind/around the sphere, plus an `isConnected` guard on the cached install-section anchor (a detached node's rect is all zeros → `scrollVy` locked at 1 and the eye stared down forever after HMR/route swaps — the reported "tracking breaks after scrolling"); install-extras cards un-crunched from 2-col to stacked full-width with one-liners wrapping (`pre-wrap`) instead of horizontal-scrolling. Verified live via Orca browser screenshots: gaze tracks cursor left/right post-scroll, halo clean, no horizontal scroll.
|
||||
|
||||
**Server-side root cause + fix (same evening, via SSH).** `ss -tlnp` on docker-server showed the real story: API (`:8642`) and relay (`:8767`) on `0.0.0.0`, but `hermes-dashboard.service` ran with `--host 192.168.1.100` — LAN interface only, so `100.64.0.100:9119` was connection-refused (not 401, hence no sign-in card; the "existing login" observed on-device was last-known persisted state). Rebound to `--host 0.0.0.0` + restart (authorized via prompt), verified `/api/status` on both IPs, updated the server's `~/SYSTEM.md` services table. Phone (adb) confirmed end-to-end: Manage's new target line showed `100.64.0.100:9119 · Tailscale route`, banner flipped to "sign-in required", sign-in card rendered with the route strip. Two learnings recorded: the app's `DashboardCookieJar` is per-connection, NOT host-scoped (sends the stored session cookie to whichever host the route resolves to — sessions normally roam; the restart wiping in-memory dashboard sessions is what forced re-sign-in), and the sign-in strip's "per host" wording could be tightened later.
|
||||
**Server-side root cause + fix (same evening, via SSH).** `ss -tlnp` on hermes-host showed the real story: API (`:8642`) and relay (`:8767`) on `0.0.0.0`, but `hermes-dashboard.service` ran with `--host 192.168.1.100` — LAN interface only, so `100.64.0.100:9119` was connection-refused (not 401, hence no sign-in card; the "existing login" observed on-device was last-known persisted state). Rebound to `--host 0.0.0.0` + restart (authorized via prompt), verified `/api/status` on both IPs, updated the server's `~/SYSTEM.md` services table. Phone (adb) confirmed end-to-end: Manage's new target line showed `100.64.0.100:9119 · Tailscale route`, banner flipped to "sign-in required", sign-in card rendered with the route strip. Two learnings recorded: the app's `DashboardCookieJar` is per-connection, NOT host-scoped (sends the stored session cookie to whichever host the route resolves to — sessions normally roam; the restart wiping in-memory dashboard sessions is what forced re-sign-in), and the sign-in strip's "per host" wording could be tightened later.
|
||||
|
||||
**Manage loading/overview pass (same day).** Three complaints: the cold-load skeleton stacked four progress bars with fake narrative labels; every re-entry to Manage was a cold load; the KPI glyphs (`ok/…/!`) and the one-line status banner (truncated by two trailing buttons, one a duplicate "Connection" link) were weak. Shipped: (1) **process-lifetime payload cache** — `DashboardPayloadCache` singleton replaces the `remember{}` maps, keyed `connection|dashboardUrl|section` so connection switches and route handoffs stay partitioned; `Loaded.fetchedAtMillis` drives a 30s stale-while-revalidate window (fresh → no fetch; stale → cached content + thin refresh bar); sign-in/out clears as before. (2) **App-start pre-warm** — section fetch core extracted to `fetchDashboardSectionState()`; `prewarmDashboardManage()` (internal, same file) fills cold keys only, aborts the sweep on first unreachable/auth failure, never marks Loading so it can't fight the open screen; RelayApp fires it (1.5s debounce) when the persisted snapshot says reachable + signed-in/auth-free, re-firing on route handoff. (3) **Skeleton** — one LinearProgressIndicator + three pulsing content-shaped ghost cards. (4) **KPI strip** — count / tone-colored dashboard state word (ready/sign-in/offline/error) / server version (`RelayMetricCard` gains optional `valueColor`). (5) **Status banner** — two-line layout (state+identity+Sign out / URL·route·checked), duplicate "Connection" button removed (Connections tile is directly below).
|
||||
|
||||
|
||||
+14
-27
@@ -1,22 +1,22 @@
|
||||
# Hermes-Relay-Android v1.2.1
|
||||
# Hermes-Relay-Android v1.2.3
|
||||
|
||||
**Release Date:** June 21, 2026
|
||||
**Since v1.2.0:** A focused follow-up to the big personalization release — add a **profile lock**, an in-app **changelog**, a clean **diagnostics → report** flow, and a non-nagging **update nudge**, plus a round of voice and realtime reliability fixes.
|
||||
**Release Date:** June 23, 2026
|
||||
**Since v1.2.2:** A connection-stability hotfix. Connecting to a server over an **encrypted link** (Tailscale Serve or public HTTPS) could hard-close the app the moment the connection came up; that crash is fixed, so securing your connection no longer force-closes Hermes-Relay.
|
||||
|
||||
v1.2.1 builds on 1.2.0's personalization and transparency themes with quality-of-life and reliability work. Pin the app to one agent profile, review past release notes any time, turn a logged error into a one-tap GitHub issue, and get a tasteful in-app prompt when a newer build is live. Voice mode is calmer and more correct — Stop actually stops, hold-to-talk is steadier, the overlay is readable — and realtime turns that reach back to Hermes no longer fail with a session error.
|
||||
v1.2.3 is a focused fix for anyone connecting over Tailscale or public TLS. Plain-LAN connections were never affected.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
v1.2.1 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
v1.2.3 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
|
||||
| Flavor | File | Who it's for |
|
||||
|---|---|---|
|
||||
| Google Play | `hermes-relay-1.2.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, or unattended phone control. |
|
||||
| sideload | `hermes-relay-1.2.1-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-1.2.1-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-1.2.1-sideload-release.aab` | Parity/testing artifact. |
|
||||
| Google Play | `hermes-relay-1.2.3-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
|
||||
| sideload | `hermes-relay-1.2.3-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-1.2.3-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-1.2.3-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.
|
||||
|
||||
@@ -24,25 +24,12 @@ Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload
|
||||
|
||||
## Highlights
|
||||
|
||||
### Make it yours
|
||||
- **Profile lock.** Pin the app to a single agent profile from Settings → Profile lock. Every other profile picker collapses to a locked state, and the lock screen stays the one place that lists every profile — with a clear notice if the locked profile isn't on the current server.
|
||||
|
||||
### Find your way back
|
||||
- **In-app What's New & changelog.** A new Settings entry shows the current and past release notes any time, not just the post-update popup.
|
||||
|
||||
### When something breaks
|
||||
- **Diagnostics → tap for detail + report.** Logged errors now carry clean titles and open a detail view with **Copy / Share / Create-GitHub-issue** — the same flow as crash reports. Classified errors across voice, chat, and connection are captured centrally.
|
||||
- **Update-available nudge.** A dismissable in-app banner when a newer version is live — Google Play In-App Update on Play installs, GitHub Releases on sideload. Per-version dismissal, throttled, never nags.
|
||||
|
||||
### Voice & realtime
|
||||
- **Voice override applies in Auto mode.** A chosen per-profile/enhanced voice now takes effect on Auto with the relay paired (previously only "Relay" mode applied it); voice settings are also namespaced per connection.
|
||||
- **Realtime "Stop" stops immediately**, over-chatty spoken status is throttled, and long background tasks no longer time out the turn.
|
||||
- **Steadier voice controls.** Hold-to-talk holds until you genuinely lift your finger, and the voice overlay is readable — opaque panel and status bubbles, non-wrapping labels, and invalid engine/route combinations disabled.
|
||||
- **Connection status overlay** clears faster — resolved (error/warning) toasts auto-dismiss within ~5s instead of lingering.
|
||||
### Fixed
|
||||
- **No more crash on connect over TLS / Tailscale.** Connecting over an encrypted link (Tailscale Serve or public HTTPS) could force-close the app with a `NetworkOnMainThreadException` as the connection came up — a live SSL socket was being closed on the main thread during client teardown, and a TLS close performs a network write. Socket teardown now always runs off the main thread, so connecting over a secured link is stable. Plain-LAN connections were never affected.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade notes
|
||||
- All new app features are available on **both** flavors (client-side; no Device Control needed).
|
||||
- **Relay-side fix (ships in the plugin, not the APK):** brokered Realtime Agent turns that reach back to Hermes no longer fail with `session_not_found` — the relay now mints/reuses a valid API-server session and reads the current nested create-session response. Relay operators pick this up via `hermes-relay-update`.
|
||||
- `appVersionCode` is **15**.
|
||||
- This is an app-side fix on **both** flavors — no Device Control or server changes needed.
|
||||
- If you were crashing on connect over Tailscale or HTTPS, update and reconnect.
|
||||
- `appVersionCode` is **17**.
|
||||
|
||||
@@ -8,18 +8,30 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
## User-Added:
|
||||
|
||||
- [ ] Enhance the 'clean chat' view mode to allow more a little more vertical visible text area and scrolling within.
|
||||
- [x] **Clean-chat: taller scrollable text viewport** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* Replaced the fragile `screenHeightDp*0.34f` cap with a weight split (sphere `weight(1f)` / flow `weight(1.1f)` ≈ 52% of the vertical slack); kept the internal scroll + top-fade + `min=96.dp` floor. `AgentTextFlow.kt` (`1dca285`).
|
||||
- [ ] Verify profile selection retains voice config selections in all voice modes/configuration combinations - enhance UI/configurability/management for this.
|
||||
- [x] **Session delete on a non-default profile now persists** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* Root cause: a non-default profile's sessions live in that profile's own `state.db`, but the delete went through the unscoped api_server `DELETE /api/sessions/{id}` (shared DB) so the row survived and the next profile-scoped list resurrected it. Fix routes gateway deletes through the dashboard profile-scoped surface (write twin of the list path) + `refreshSessions()` after success. `DashboardApiClient`/`ConnectionViewModel`/`ChatViewModel`/`RelayApp` (`6552566`).
|
||||
- [x] **Voice-settings profile override in 'auto' mode** *(impl 2026-06-21, orchestration batch — unbuilt; verify in Studio. See DEVLOG + "Orchestration batch (2026-06-21)" below.)* Root cause: `VoiceViewModel.shouldPreferRealtimeVoice()` gated on `.route` (configured) not `.effectiveRoute` (resolved), so 'auto'+relay never engaged the override-capable relay path and fell back to host-global Standard `/api/audio/speak` (no override slot). Fixed + wired `connectionId` for per-profile voice-prefs namespacing. Original note: *Look into the voice-settings profile specific capabilities - in 'auto' mode the user-override voice wasn't applied (system default used) despite being displayed; only 'Relay' applied it.*
|
||||
|
||||
- [ ] Analytics and diagnostics pages need cleaned up, improved, enhancements for UI/UX/layout. Diagnostics should have timeline vertical status checks with failure reason etc.
|
||||
- [x] **Analytics + Diagnostics overhaul** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* Diagnostics is now a full-screen `DiagnosticsScreen` (new `Screen.Diagnostics` route, replacing the modal sheet) led by a vertical status-check timeline — Network, API server, capabilities, chat transport, pairing/auth, relay, voice — each a green/amber/red/gray dot on a connecting rail with an inline failure reason; checks backed by a logged error are tappable into `DiagnosticDetailDialog`. Derived read-only from existing `ConnectionViewModel` flows + recent `DiagnosticsLog` via a pure `buildStatusChecks()`; recent-activity log kept below. Analytics hierarchy tidied. `c3098a9`. See follow-ups below.
|
||||
- [x] **Realtime voice stall + over-chatty status** *(client half impl 2026-06-21, orchestration batch — unbuilt; server half deferred, see below.)* Client now relaxes the 90s idle watchdog on promoted/long runs (5-min backstop kept) and throttles spoken status (≥22s gap, ≤3/turn); realtime waveform now gates on real playback-start. Original note: *Realtime voice mode stalls/times-out when calling a background Hermes task and repeatedly reports status vocally when not necessary.*
|
||||
- [ ] Clean up connections page - reframe standard/vanilla Hermes as just 'Hermes' - relay enhanced connection becomes 'Relay' or 'Relay plugin' where descriptively appropriate.
|
||||
- [x] **Connections reframe: "Vanilla/Standard Hermes" → "Hermes"** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* 28 user-facing display strings across 10 connection/voice/permissions files; "Hermes-Relay plugin" → "Relay plugin" where it reads naturally. Display text only — no enum names, sealed types, when-branches, or stored route values touched. `c9fa8f7`.
|
||||
- [x] **Lock app to a specific profile** *(impl 2026-06-21, orchestration batch — unbuilt; verify in Studio.)* Per-connection lock: new `ProfileLockStore`, `ProfileController` lock flows + enforcement, `ConnectionInfoSheet` collapses the picker to a static "Locked to <name>" row, `SettingsScreen` adds the lock card + dialog (the one surface still listing all profiles). Original note: *Allow locking app to a specific profile, hiding all other profiles except from this setting - cleanly hide profile specific UI elements based on this gate.*
|
||||
- [x] **Profile icon in the floating voice overlay** *(impl 2026-06-21, orchestration batch — unbuilt.)* `VoiceModeOverlay` header pill now shows the per-profile icon (`LocalAgentIconPath`); sphere/pet stays the fallback.
|
||||
- [x] **Voice dropdown state mixes + label overflow** *(impl 2026-06-21, orchestration batch — unbuilt.)* Invalid engine/route combos made unreachable (RealtimeAgent disabled without relay, unavailable routes disabled, `coerceAudioRoute` auto-corrects); long dropdown/provider labels get `maxLines=1`+ellipsis. Original note: *Fix the voice dropdown mode toggles to not allow weird state mixes - labels need overflow control to prevent 2 lines or crunching.*
|
||||
|
||||
- [x] **Per-profile agent icon + static-image avatar (shipped 2026-06-20 —** `d827e46`**, see DEVLOG).** Per-profile icon: client-side `ProfileIconStore` (per `(connection, profile)`, never sent to Hermes; stores a copied-file path) → small Coil image beside the agent name in `MessageBubble` via `LocalAgentIconPath`; picker is `AgentIconRow` under the local-name row in `ConnectionInfoSheet`. Static image: "Add a pet" accepts a single image (magic-byte detect → one-frame static pet). Scope shipped: small name-adjacent icon only; big avatar stays global. Follow-ups: on-device smoke (import an image as a pet; set a profile icon, confirm it shows by the name + persists across restart); optionally also show the icon in the profile picker.
|
||||
|
||||
## Orchestration batch (2026-06-22) — deferred follow-ups
|
||||
|
||||
Four User-Added items resolved via a 4-worker orchestration pass (disjoint file ownership, coordinator-serialized commits): clean-chat viewport (`1dca285`), connections reframe (`c9fa8f7`), diagnostics/analytics (`c3098a9`), session-delete fix (`6552566`). Plus a follow-on profile-isolation fix raised mid-session: cold-start session-drawer hydration (`889273a`). **Committed to `dev`, NOT built/linted/verified.** Remaining:
|
||||
|
||||
- **Build + lint + on-device verify all five (Studio).** Run `./gradlew lint` and a Studio build before pushing `dev` (workers couldn't run gradle). Then confirm on device: clean-chat shows a noticeably taller text area that scrolls; deleting a session on a *non-default* profile sticks (no resurrection after the drawer re-fetches); the Diagnostics screen renders honest per-check status + failure reasons and opens detail on a failing tappable row; connections/voice/permissions copy reads "Hermes"/"Relay"; **and on a cold start while a non-default profile is selected, the session drawer loads that profile's sessions directly with no flash of the server-default list.**
|
||||
- **Profile isolation — broader sweep (cold-start race).** The session drawer + restored session context are now gated on `ProfileController.selectionSettled` (`889273a`), so they no longer load the server-default profile before the persisted profile resolves. Other profile-scoped surfaces read the *live* `selectedProfile.value` and self-correct when it resolves but aren't gated: voice prefs (`VoiceViewModel.onProfileChanged` at the `RelayApp` voice effect), `profileDisplayAlias`, `profileIcon`. They re-seed on resolution (no visible content-flash like the drawer), but if any shows a wrong-profile beat on cold start, gate its first use on `profileSelectionSettled` the same way. Also: `selectionSettled`'s decision logic is unit-testable (pure over connId/selected/pending/profiles) — add a `ProfileControllerSettledTest` when convenient.
|
||||
- **Diagnostics: no live re-probe trigger.** The status checks reflect the *last* probe state (read-only snapshot). A "Re-run checks" button would need `ConnectionViewModel` to expose probe methods — deferred so the diagnostics work didn't have to edit a concurrently-owned VM.
|
||||
- **Diagnostics: Pass checks lack a last-checked timestamp/duration.** `StatusCheck` carries `timestampMs`/`durationMs`, but the VM doesn't expose probe timing, so passing rows show no "checked Ns ago". Wire when/if the VM surfaces probe timestamps.
|
||||
- **Connections reframe — out-of-scope occurrences left intentionally.** `ConnectionViewModel.kt`, `VoiceAudioClient.kt`, `VoiceViewModel.kt`, `BridgeCoreScreen.kt`, and `RelayApp.kt` still contain "Standard"/"Vanilla" in code identifiers/log strings; only user-facing display copy was reframed. Revisit if any of those surface to users.
|
||||
|
||||
## Orchestration batch (2026-06-21) — deferred follow-ups
|
||||
|
||||
Client-side profile-lock + voice fixes (the items marked above) landed via a planning→implementation orchestration pass, **built + deployed to device as 1.2.1 (versionCode 15)**; new unit suite green (36 Kotlin + 11 Python). On-device behaviour verification still pending. Remaining from that batch:
|
||||
@@ -30,7 +42,7 @@ Client-side profile-lock + voice fixes (the items marked above) landed via a pla
|
||||
- **Profile lock: ChatScreen glyph + export.** The optional lock glyph on the chat-header avatar was skipped (`ChatScreen.kt` is owned by a concurrent session). Decide whether the per-connection lock belongs in settings export/import (it rides the `profile_selections` DataStore).
|
||||
- **Unit tests — DONE 2026-06-21 (36/36 pass via `:app:testSideloadDebugUnitTest`).** `ProfileLockStoreTest` (9 — uses an in-memory `DataStore` harness; the file-backed factory hits a Windows write-rename/instance race), `ProfileControllerLockTest` (8, Robolectric), `CoerceAudioRouteTest` (7), `VoiceStatusGatesTest` (12).
|
||||
- **CHANGELOG.** Add `[Unreleased]` entries (Profile lock → Added; voice override + realtime → Fixed) at build-verify/PR time.
|
||||
- **On-device verification.** Override applies in 'auto'+relay; realtime survives a >90s background task without stalling and stops over-narrating; Speaking waveform unfolds at first audible frame; profile lock hides pickers + holds on a missing profile; overlay shows the profile icon.
|
||||
- **On-device verification.** Override applies in 'auto'+relay; realtime survives a >90s background task without stalling and stops over-narrating; Speaking waveform unfolds at first audible frame; profile lock hides pickers + holds on a missing profile; overlay shows the profile icon.
|
||||
|
||||
## Hands-free agentic voice backlog
|
||||
|
||||
@@ -170,7 +182,7 @@ When the answer becomes clearer, this section becomes either an ADR in `docs/dec
|
||||
- **LLM client wiring for `android_navigate`** — `_default_vision_model` is stubbed; production swap to a real Anthropic/OpenAI vision client
|
||||
- **Real screenshots of each flavor's a11y permission dialog** — for `user-docs/guide/release-tracks.md`
|
||||
- `**llms.txt` standard** — explicitly skipped in favor of the `hermes-relay-self-setup` SKILL.md path; revisit if the standard gains traction in the agent ecosystem
|
||||
- `**markdown-renderer` 0.40.x API update** — pinned at `0.30.0` in `gradle/libs.versions.toml` because 0.40.2 introduced breaking API changes that `app/src/main/kotlin/com/hermesandroid/relay/ui/components/MarkdownContent.kt` hasn't been updated for. Specifically: `markdownColor()` drops `codeText`/`linkText`, `MarkdownCodeBlock`/`MarkdownCodeFence` inner lambdas now take a 3rd `TextStyle` arg, and `MarkdownHighlightedCode`'s 3rd param is now `TextStyle` instead of `Highlights.Builder`. Dependabot auto-merged the bump on 2026-04-13 which silently broke CI; reverted for the v0.3.0 release. Update requires reading the new library API docs and testing in Studio — not a blind fix. Consider adding a dependabot ignore rule for `markdown-renderer` major bumps until this is handled.
|
||||
- `**markdown-renderer`/`lifecycle` compileSdk ceiling — RESOLVED via compileSdk 37 (2026-06-22).** `MarkdownContent.kt` is on the 0.4x API, and `markdown-renderer 0.42.0` / `lifecycle 2.11.0` (the Dependabot bumps) require `compileSdk 37`. The project moved to **compileSdk 37** (`206d182`, across app/quest/relay-core/relay-ui; `targetSdk` stays 35), which satisfies them — so the temporary 1.2.2-prep pins (0.41.0 / 2.10.0 on compileSdk 36) were dropped when integrating `origin/dev`. **CLAUDE.md still says "Compile SDK 36" — update it to 37 to match the build.** A Dependabot ignore rule is still worth adding so a future bump that raises the compileSdk floor again fails loudly rather than silently (see next item).
|
||||
- **Dependabot auto-merge guardrails** — Dependabot merged breaking bumps despite CI failing. Investigate why `.github/workflows/dependabot-auto-merge.yml` isn't gating on CI status, and consider adding an ignore rule for packages we know need manual attention on major bumps (`markdown-renderer`, compose BOM, activity-compose).
|
||||
|
||||
---
|
||||
|
||||
@@ -27,7 +27,7 @@ android {
|
||||
// and `applicationId` is the runtime install identity; they don't have
|
||||
// to match.
|
||||
namespace = "com.hermesandroid.relay"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
// Axiom-Labs, LLC Play Console listing. Changed from the original
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
v1.2.1 — Polish & control.
|
||||
v1.2.3 — Connection crash fix.
|
||||
|
||||
• Lock the app to a single agent profile and hide the rest.
|
||||
• In-app "What's New" with current and past release notes.
|
||||
• Diagnostics with clean error titles and one-tap reporting.
|
||||
• A tasteful, dismissable "update available" nudge.
|
||||
• Voice fixes: Stop halts speech instantly, steadier hold-to-talk, a more readable overlay, and chosen voices apply in Auto mode.
|
||||
• Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS). Connecting over a secured connection is now stable. Plain local-network connections were never affected.
|
||||
|
||||
@@ -1,5 +1,78 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"title": "Connection crash fix",
|
||||
"date": "2026-06-23",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS) — a live secure connection was being torn down on the main thread as it came up. Securing your connection no longer force-closes the app; plain-LAN connections were never affected."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.2",
|
||||
"title": "Multi-profile polish",
|
||||
"date": "2026-06-22",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Profiles that behave",
|
||||
"bullets": [
|
||||
"Deleting a session while a non-default agent profile is active now sticks — it no longer reappears after the list refreshes.",
|
||||
"On a cold start with a non-default profile selected, the session drawer opens on that profile's chats directly instead of briefly showing the default profile's."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Clearer diagnostics",
|
||||
"bullets": [
|
||||
"Diagnostics is now a full screen led by a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a pass / warning / fail state and the reason when something's wrong; tap a failing check for full detail. The recent-activity log stays below."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Small touches",
|
||||
"bullets": [
|
||||
"The default connection is now simply \"Hermes\" (and the optional power features are labelled \"Relay\"), across setup, the switcher, voice, and permissions.",
|
||||
"Distraction-free chat mode gives its text a taller, scrollable area."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.1",
|
||||
"title": "Polish & control",
|
||||
"date": "2026-06-21",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Yours to control",
|
||||
"bullets": [
|
||||
"Lock the app to a single agent profile (Settings → Profile lock) and hide the rest from the pickers."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Find your way back",
|
||||
"bullets": [
|
||||
"A new \"What's New\" entry in Settings shows current and past release notes any time — not just after an update."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "When something breaks",
|
||||
"bullets": [
|
||||
"Diagnostics show clean error titles — tap any entry for a detail view with Copy, Share, and a one-tap GitHub issue.",
|
||||
"A tasteful in-app banner tells you when a newer version is live (Play or sideload) — dismissable, and it never nags."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice fixes",
|
||||
"bullets": [
|
||||
"Stop now halts realtime speech instantly, hold-to-talk is steadier, the voice overlay is easier to read, and a chosen voice applies in Auto mode.",
|
||||
"Realtime turns that reach back to Hermes no longer drop with a session error."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"title": "Make it yours",
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
v1.2.1 - Polish & control
|
||||
v1.2.3 - Connection crash fix
|
||||
|
||||
Yours to control
|
||||
* Lock the app to a single agent profile (Settings → Profile lock) and hide
|
||||
the rest from the pickers.
|
||||
|
||||
Find your way back
|
||||
* A new "What's New" entry in Settings shows current and past release notes
|
||||
any time — not just after an update.
|
||||
|
||||
When something breaks
|
||||
* Diagnostics now show clean error titles — tap any entry for a detail view
|
||||
with Copy, Share, and a one-tap GitHub issue.
|
||||
* A tasteful in-app banner tells you when a newer version is live (Play or
|
||||
sideload) — dismissable, and it never nags.
|
||||
|
||||
Voice fixes
|
||||
* Stop now halts realtime speech instantly, hold-to-talk is steadier, the
|
||||
voice overlay is easier to read, and a chosen voice applies in Auto mode.
|
||||
* Realtime turns that reach back to Hermes no longer drop with a session
|
||||
error.
|
||||
Stability
|
||||
* Fixed a crash that could close the app right after connecting over an
|
||||
encrypted link (Tailscale or HTTPS). Securing your connection no longer
|
||||
force-closes the app. Plain-LAN connections were never affected.
|
||||
|
||||
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonObjectBuilder
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
@@ -702,6 +703,18 @@ class AuthManager(
|
||||
pendingEndpoints = endpoints?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability negotiation advertised in the first system/auth envelope.
|
||||
* Older relays ignore this object; newer relays use it to send versioned
|
||||
* `chat:stream.event` payloads instead of flattening Hermes SSE into text.
|
||||
*/
|
||||
private fun JsonObjectBuilder.putRelayClientSupports() {
|
||||
put("supports", buildJsonObject {
|
||||
put("typed_stream_events", true)
|
||||
put("event_schema_version", 1)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send auth envelope when connection is established.
|
||||
*
|
||||
@@ -737,6 +750,7 @@ class AuthManager(
|
||||
}
|
||||
put("device_id", deviceId)
|
||||
put("device_name", android.os.Build.MODEL)
|
||||
putRelayClientSupports()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
@@ -752,6 +766,7 @@ class AuthManager(
|
||||
put("pairing_code", codeToSend)
|
||||
put("device_id", deviceId)
|
||||
put("device_name", android.os.Build.MODEL)
|
||||
putRelayClientSupports()
|
||||
pendingTtlSeconds?.let { put("ttl_seconds", it) }
|
||||
pendingGrants?.let { grants ->
|
||||
val obj = buildJsonObject {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Single source of truth for "is this connection encrypted, and by what?"
|
||||
*
|
||||
* Security is **per-surface**: a single paired connection fans out to several
|
||||
* transports (chat/gateway + Manage over the dashboard, API/sessions, relay
|
||||
* tools) and each can independently be TLS, overlay-encrypted, or plain (see
|
||||
* [computeConnectionSecurity]). Every UI surface — the chat status chip, the
|
||||
* connection header, the route picker, the detail sheet — renders the same
|
||||
* derived [ConnectionSecurity] so no two places disagree about what "secure"
|
||||
* means.
|
||||
*
|
||||
* Crucially, **"encrypted" includes overlay transports** (Tailscale/WireGuard,
|
||||
* the plugin secure proxy), not just TLS. A `ws://` link over a tailnet is
|
||||
* WireGuard-encrypted end-to-end — genuinely secure, just not TLS — so it is
|
||||
* never labelled "insecure". Only a plain scheme with no overlay warns.
|
||||
*/
|
||||
enum class SurfaceSecurityKind { Tls, Overlay, Plain }
|
||||
|
||||
/** Connection-level rollup across the surfaces actually in use. */
|
||||
enum class ConnectionSecurityLevel { Tls, Overlay, Mixed, Plain, Unknown }
|
||||
|
||||
/** Security verdict for one transport surface of a connection. */
|
||||
data class SurfaceSecurity(
|
||||
val label: String,
|
||||
val kind: SurfaceSecurityKind,
|
||||
/** Human mechanism: "TLS", "Tailscale", "WireGuard", "Proxy", "Plain". */
|
||||
val mechanism: String,
|
||||
val url: String,
|
||||
)
|
||||
|
||||
data class ConnectionSecurity(
|
||||
val level: ConnectionSecurityLevel,
|
||||
/** Dominant mechanism for the at-a-glance label. */
|
||||
val mechanism: String,
|
||||
val surfaces: List<SurfaceSecurity>,
|
||||
) {
|
||||
/** True when every in-use surface is encrypted (TLS or overlay). */
|
||||
val isEncrypted: Boolean
|
||||
get() = level == ConnectionSecurityLevel.Tls || level == ConnectionSecurityLevel.Overlay
|
||||
|
||||
companion object {
|
||||
val UNKNOWN = ConnectionSecurity(ConnectionSecurityLevel.Unknown, "", emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the URL scheme is TLS (`wss://` / `https://`). */
|
||||
fun isTlsUrl(url: String?): Boolean {
|
||||
if (url.isNullOrBlank()) return false
|
||||
val lower = url.trim().lowercase()
|
||||
return lower.startsWith("wss://") || lower.startsWith("https://")
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the active route is encrypted by an overlay network (Tailscale /
|
||||
* WireGuard) or the plugin secure proxy, even if its scheme is plain. Mirrors
|
||||
* the logic that previously lived privately in `ActiveConnectionSections`.
|
||||
*/
|
||||
fun EndpointCandidate?.isEncryptedOverlayRoute(isTailscaleDetected: Boolean): Boolean {
|
||||
if (this == null) return false
|
||||
val r = role.lowercase()
|
||||
val hint = security.orEmpty().lowercase()
|
||||
return r == "tailscale" ||
|
||||
(isTailscaleDetected && hint.contains("tailscale")) ||
|
||||
r == "plugin_proxy" ||
|
||||
r == "plugin-proxy" ||
|
||||
hasSecureProxy() ||
|
||||
hint.contains("wireguard") ||
|
||||
hint.contains("https") ||
|
||||
hint.contains("tls")
|
||||
}
|
||||
|
||||
/** Human label for the overlay mechanism encrypting a route. */
|
||||
fun EndpointCandidate?.overlayMechanism(isTailscaleDetected: Boolean): String {
|
||||
if (this == null) return "Encrypted"
|
||||
val r = role.lowercase()
|
||||
val hint = security.orEmpty().lowercase()
|
||||
return when {
|
||||
r == "tailscale" || (isTailscaleDetected && hint.contains("tailscale")) -> "Tailscale"
|
||||
r == "plugin_proxy" || r == "plugin-proxy" || hasSecureProxy() -> "Proxy"
|
||||
hint.contains("wireguard") -> "WireGuard"
|
||||
hint.contains("https") || hint.contains("tls") -> "TLS"
|
||||
else -> "Encrypted"
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify a single surface URL against the active route. */
|
||||
fun classifySurfaceSecurity(
|
||||
label: String,
|
||||
url: String,
|
||||
activeEndpoint: EndpointCandidate?,
|
||||
isTailscaleDetected: Boolean,
|
||||
): SurfaceSecurity {
|
||||
val (kind, mechanism) = when {
|
||||
isTlsUrl(url) -> SurfaceSecurityKind.Tls to "TLS"
|
||||
activeEndpoint.isEncryptedOverlayRoute(isTailscaleDetected) ->
|
||||
SurfaceSecurityKind.Overlay to activeEndpoint.overlayMechanism(isTailscaleDetected)
|
||||
else -> SurfaceSecurityKind.Plain to "Plain"
|
||||
}
|
||||
return SurfaceSecurity(label = label, kind = kind, mechanism = mechanism, url = url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll up the per-surface verdicts into one connection-level [ConnectionSecurity].
|
||||
* Pure + side-effect free so it is unit-testable without Android.
|
||||
*/
|
||||
fun computeConnectionSecurity(
|
||||
apiUrl: String,
|
||||
dashboardUrl: String,
|
||||
relayUrl: String,
|
||||
relayConfigured: Boolean,
|
||||
activeEndpoint: EndpointCandidate?,
|
||||
isTailscaleDetected: Boolean,
|
||||
): ConnectionSecurity {
|
||||
val surfaces = buildList {
|
||||
dashboardUrl.trim().takeIf { it.isNotBlank() }?.let {
|
||||
add(classifySurfaceSecurity("Chat & Manage", it, activeEndpoint, isTailscaleDetected))
|
||||
}
|
||||
apiUrl.trim().takeIf { it.isNotBlank() }?.let {
|
||||
add(classifySurfaceSecurity("API / sessions", it, activeEndpoint, isTailscaleDetected))
|
||||
}
|
||||
if (relayConfigured) {
|
||||
relayUrl.trim().takeIf { it.isNotBlank() }?.let {
|
||||
add(classifySurfaceSecurity("Relay tools", it, activeEndpoint, isTailscaleDetected))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (surfaces.isEmpty()) return ConnectionSecurity.UNKNOWN
|
||||
|
||||
val kinds = surfaces.map { it.kind }.toSet()
|
||||
val hasPlain = SurfaceSecurityKind.Plain in kinds
|
||||
val hasSecure = kinds.any { it != SurfaceSecurityKind.Plain }
|
||||
|
||||
val level = when {
|
||||
!hasSecure -> ConnectionSecurityLevel.Plain
|
||||
hasPlain -> ConnectionSecurityLevel.Mixed
|
||||
kinds == setOf(SurfaceSecurityKind.Tls) -> ConnectionSecurityLevel.Tls
|
||||
else -> ConnectionSecurityLevel.Overlay
|
||||
}
|
||||
|
||||
val mechanism = when (level) {
|
||||
ConnectionSecurityLevel.Tls -> "TLS"
|
||||
ConnectionSecurityLevel.Overlay ->
|
||||
surfaces.firstOrNull { it.kind == SurfaceSecurityKind.Overlay }?.mechanism ?: "Encrypted"
|
||||
ConnectionSecurityLevel.Mixed -> "Mixed"
|
||||
ConnectionSecurityLevel.Plain -> when (activeEndpoint?.role?.lowercase()) {
|
||||
"lan" -> "LAN"
|
||||
"public" -> "Public"
|
||||
null, "" -> "Plain"
|
||||
else -> activeEndpoint.role
|
||||
}
|
||||
ConnectionSecurityLevel.Unknown -> ""
|
||||
}
|
||||
return ConnectionSecurity(level = level, mechanism = mechanism, surfaces = surfaces)
|
||||
}
|
||||
@@ -36,6 +36,35 @@ data class DiagnosticLogEntry(
|
||||
val stacktrace: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Current health of a single subsystem on the Diagnostics status timeline.
|
||||
*
|
||||
* Distinct from [DiagnosticSeverity], which classifies a *logged event* after
|
||||
* the fact. A [CheckStatus] is the *live* state of a subsystem, derived
|
||||
* read-only from connection state + the recent [DiagnosticsLog]. [Unknown] is
|
||||
* a first-class, honest state — "not checked / not applicable" — never an
|
||||
* implied pass or fail.
|
||||
*/
|
||||
enum class CheckStatus { Pass, Warn, Fail, Unknown }
|
||||
|
||||
/**
|
||||
* One row on the Diagnostics status timeline: a named subsystem check with its
|
||||
* current [status] and, when not [CheckStatus.Pass], a human [reason] — the
|
||||
* whole point of the screen is answering "why is this failing?".
|
||||
*
|
||||
* [category] links the check back to a [DiagnosticCategory]; when [timestampMs]
|
||||
* is non-null the reason came from a concrete [DiagnosticLogEntry], so the row
|
||||
* is tappable and the UI can open that entry's full detail.
|
||||
*/
|
||||
data class StatusCheck(
|
||||
val name: String,
|
||||
val status: CheckStatus,
|
||||
val reason: String? = null,
|
||||
val category: DiagnosticCategory? = null,
|
||||
val timestampMs: Long? = null,
|
||||
val durationMs: Long? = null,
|
||||
)
|
||||
|
||||
object DiagnosticsLog {
|
||||
private const val MAX_ENTRIES = 200
|
||||
private const val MAX_TEXT_LENGTH = 180
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.os.Looper
|
||||
|
||||
/**
|
||||
* Run an OkHttp teardown [block] without ever performing a network write on
|
||||
* the main thread.
|
||||
*
|
||||
* [okhttp3.ConnectionPool.evictAll] closes pooled sockets synchronously. For
|
||||
* a live `https`/`wss` keep-alive connection that close drains the SSL output
|
||||
* queue — a real network write (`SSLOutputStream.writeInternal`) — which trips
|
||||
* StrictMode's [android.os.NetworkOnMainThreadException]. Reported as a hard
|
||||
* crash on connect over TLS/Tailscale (issues #70 / #118 / #124): a
|
||||
* `viewModelScope` (i.e. `Dispatchers.Main.immediate`) coroutine resumes on the
|
||||
* main thread and shuts a dashboard/API client down in a `finally` block.
|
||||
*
|
||||
* Client shutdown is fire-and-forget cleanup, so when the caller is on the main
|
||||
* thread we hand [block] to a short-lived daemon thread. Off the main thread
|
||||
* (already on `Dispatchers.IO` or a background thread) we run it inline so
|
||||
* callers that deliberately moved off main keep their ordering and any blocking
|
||||
* `awaitTermination` waits stay where the caller put them.
|
||||
*/
|
||||
internal fun shutdownOffMainThread(threadName: String, block: () -> Unit) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
Thread({ runCatching(block) }, threadName).apply { isDaemon = true }.start()
|
||||
} else {
|
||||
block()
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import com.hermesandroid.relay.network.shared.EndpointResolver
|
||||
import com.hermesandroid.relay.network.shutdownOffMainThread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -705,8 +706,12 @@ class ConnectionManager(
|
||||
disconnect()
|
||||
unregisterNetworkCallback()
|
||||
supervisorJob.cancel()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.connectionPool.evictAll()
|
||||
// evictAll() closes live wss sockets synchronously; on a TLS keep-alive
|
||||
// that close is a network write, so keep it off the main thread.
|
||||
shutdownOffMainThread("ConnectionManager-shutdown") {
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.connectionPool.evictAll()
|
||||
}
|
||||
}
|
||||
|
||||
fun send(envelope: Envelope) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.network.shared.LocalDispatchResult
|
||||
import com.hermesandroid.relay.network.upstream.GatewaySubagentEvent
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -227,6 +228,78 @@ class ChatHandler {
|
||||
private val _currentSessionId = MutableStateFlow<String?>(null)
|
||||
val currentSessionId: StateFlow<String?> = _currentSessionId.asStateFlow()
|
||||
|
||||
|
||||
/**
|
||||
* Apply a versioned Relay `stream.event` payload to native chat state.
|
||||
*
|
||||
* This is the WebSocket counterpart to the direct Hermes SSE mapper in
|
||||
* HermesApiClient: assistant deltas mutate message text, tool lifecycle
|
||||
* events update ToolProgressCard rows, progress/thinking stays in the
|
||||
* subdued reasoning area, artifacts/skill/memory notices become low-noise
|
||||
* status chips, and terminal/error/completion events explicitly settle the
|
||||
* streaming state.
|
||||
*/
|
||||
fun applyRelayStreamEvent(messageId: String, envelope: RelayStreamEventEnvelope) {
|
||||
if (envelope.type != "stream.event" || envelope.schemaVersion != 1) {
|
||||
Log.d(TAG, "Ignoring unsupported relay stream event schema: ${envelope.type} v${envelope.schemaVersion}")
|
||||
return
|
||||
}
|
||||
val payload = envelope.payload
|
||||
fun textField(vararg names: String): String? = names
|
||||
.asSequence()
|
||||
.mapNotNull { name -> (payload[name] as? JsonPrimitive)?.contentOrNull }
|
||||
.firstOrNull { it.isNotBlank() }
|
||||
fun boolField(name: String): Boolean? = (payload[name] as? JsonPrimitive)?.booleanOrNull
|
||||
val toolName = textField("tool_name", "tool", "name") ?: "unknown"
|
||||
val callId = textField("call_id", "tool_call_id") ?: toolName
|
||||
|
||||
when (envelope.event) {
|
||||
"message.started" -> {
|
||||
val msgObj = payload["message"] as? JsonObject
|
||||
val serverMsgId = (msgObj?.get("id") as? JsonPrimitive)?.contentOrNull
|
||||
if (!serverMsgId.isNullOrBlank()) replaceMessageId(messageId, serverMsgId)
|
||||
}
|
||||
"assistant.delta" -> {
|
||||
textField("delta", "content", "text")?.let { onTextDelta(messageId, it) }
|
||||
}
|
||||
"tool.progress" -> {
|
||||
textField("delta", "thinking_delta", "thinking", "text", "message")?.let {
|
||||
onThinkingDelta(messageId, it)
|
||||
}
|
||||
}
|
||||
"tool.pending", "tool.started" -> onToolCallStart(messageId, callId, toolName)
|
||||
"tool.completed" -> onToolCallComplete(messageId, callId, textField("result_preview", "summary", "message"))
|
||||
"tool.failed" -> onToolCallFailed(messageId, callId, textField("error", "message") ?: "Tool failed")
|
||||
"memory.updated", "skill.loaded" -> {
|
||||
val label = when (envelope.event) {
|
||||
"memory.updated" -> "Memory"
|
||||
else -> "Skill"
|
||||
}
|
||||
addMessageBadges(messageId, listOf(label))
|
||||
}
|
||||
"artifact.created" -> {
|
||||
addMessageBadges(messageId, listOf("Artifact"))
|
||||
textField("url", "path", "preview", "title")?.takeIf { it.isNotBlank() }?.let {
|
||||
onThinkingDelta(messageId, "Artifact: $it")
|
||||
}
|
||||
}
|
||||
"assistant.completed" -> {
|
||||
if (boolField("interrupted") == true) {
|
||||
onStreamError("Response interrupted")
|
||||
} else {
|
||||
onTurnComplete(messageId)
|
||||
}
|
||||
}
|
||||
"run.completed", "done" -> onStreamComplete(messageId)
|
||||
"error" -> {
|
||||
addMessageBadges(messageId, listOf("Error"))
|
||||
onStreamError(textField("message", "error") ?: "Unknown error")
|
||||
}
|
||||
"session.created", "run.started" -> Unit
|
||||
else -> Log.d(TAG, "Unhandled relay stream event: ${envelope.event}")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Message management ---
|
||||
|
||||
fun addUserMessage(message: ChatMessage) {
|
||||
@@ -1964,6 +2037,22 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private fun addMessageBadges(messageId: String, badges: List<String>) {
|
||||
val cleaned = badges
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
if (cleaned.isEmpty()) return
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
msg.copy(badges = (msg.badges + cleaned).distinct().take(4))
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Monotonic suffix for synthetic generating / subagent ToolCall ids. */
|
||||
private var syntheticToolSeq = 0
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.network.shutdownOffMainThread
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageListResponse
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
@@ -448,6 +449,18 @@ class DashboardApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session scoped to its owning profile via the dashboard
|
||||
* `DELETE /api/sessions/{id}?profile=`. The write twin of [listSessions]:
|
||||
* a non-default profile's sessions live in that profile's own `state.db`, so
|
||||
* deleting through the api_server (one shared DB, no profile) leaves the row
|
||||
* intact and the next profile-scoped list resurrects it. [profile] null/blank
|
||||
* → the launch profile's DB (param omitted). Mirrors [deleteCronJob]'s
|
||||
* profile-scoped delete plumbing.
|
||||
*/
|
||||
suspend fun deleteSession(sessionId: String, profile: String? = null): Result<JsonObject> =
|
||||
deleteJsonObject("/api/sessions/${pathSegment(sessionId)}${profileQuery(profile)}")
|
||||
|
||||
private fun parseProfiles(root: JsonObject): List<Profile> {
|
||||
fun decode(element: JsonElement, nameOverride: String?): Profile? = runCatching {
|
||||
val obj = element as? JsonObject ?: return null
|
||||
@@ -562,7 +575,7 @@ class DashboardApiClient(
|
||||
fun gatewayWebSocketUrl(ticket: String, path: String = "/api/ws"): String? =
|
||||
gatewayWebSocketUrl(baseUrl = baseUrl, ticket = ticket, path = path)
|
||||
|
||||
fun shutdown() {
|
||||
fun shutdown() = shutdownOffMainThread("DashboardApiClient-shutdown") {
|
||||
okHttpClient.dispatcher.executorService.shutdown()
|
||||
okHttpClient.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.network.shutdownOffMainThread
|
||||
import com.hermesandroid.relay.network.upstream.models.CreateSessionRequest
|
||||
import com.hermesandroid.relay.network.upstream.models.HermesSseEvent
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
@@ -1343,7 +1344,7 @@ class HermesApiClient(
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
fun shutdown() {
|
||||
fun shutdown() = shutdownOffMainThread("HermesApiClient-shutdown") {
|
||||
client.dispatcher.executorService.shutdown()
|
||||
try {
|
||||
if (!client.dispatcher.executorService.awaitTermination(2, TimeUnit.SECONDS)) {
|
||||
|
||||
@@ -261,6 +261,23 @@ data class MessageItem(
|
||||
// error — { message (string), error }
|
||||
// done — { session_id, run_id, state: "final" }
|
||||
|
||||
|
||||
@Serializable
|
||||
data class RelayStreamEventEnvelope(
|
||||
val type: String = "stream.event",
|
||||
@SerialName("schema_version") val schemaVersion: Int = 1,
|
||||
@SerialName("session_id")
|
||||
@Serializable(with = FlexibleIdSerializer::class)
|
||||
val sessionId: String? = null,
|
||||
@SerialName("run_id")
|
||||
@Serializable(with = FlexibleIdSerializer::class)
|
||||
val runId: String? = null,
|
||||
val seq: Int? = null,
|
||||
val event: String,
|
||||
val ts: String? = null,
|
||||
val payload: JsonObject = kotlinx.serialization.json.buildJsonObject { },
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HermesSseEvent(
|
||||
// Event type — may come as "type" or "event" depending on server version
|
||||
|
||||
@@ -86,6 +86,7 @@ import com.hermesandroid.relay.ui.components.ConnectionStatusToast
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSwitcherSheet
|
||||
import com.hermesandroid.relay.ui.components.ChatTransportStatusBadge
|
||||
import com.hermesandroid.relay.ui.components.ChatTransportTier
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSecurityGlyph
|
||||
import com.hermesandroid.relay.ui.components.PowerFeatureGateScreen
|
||||
import com.hermesandroid.relay.ui.components.PowerFeatureGateStatus
|
||||
import com.hermesandroid.relay.ui.components.RelayStatusStrip
|
||||
@@ -115,6 +116,7 @@ import com.hermesandroid.relay.ui.screens.AboutScreen
|
||||
import com.hermesandroid.relay.ui.screens.AnalyticsScreen
|
||||
import com.hermesandroid.relay.ui.screens.AppearanceSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.BridgeCoreScreen
|
||||
import com.hermesandroid.relay.ui.screens.DiagnosticsScreen
|
||||
import com.hermesandroid.relay.ui.screens.BridgeScreen
|
||||
// === PHASE3-safety-rails: bridge safety route ===
|
||||
import com.hermesandroid.relay.ui.screens.BridgeSafetySettingsScreen
|
||||
@@ -270,6 +272,7 @@ sealed class Screen(
|
||||
data object MediaSettings : Screen("settings/media", "Media", Icons.Filled.Settings)
|
||||
data object AppearanceSettings : Screen("settings/appearance", "Appearance", Icons.Filled.Settings)
|
||||
data object Analytics : Screen("settings/analytics", "Analytics", Icons.Filled.Settings)
|
||||
data object Diagnostics : Screen("settings/diagnostics", "Diagnostics", Icons.Filled.Settings)
|
||||
data object DeveloperSettings : Screen("settings/developer", "Developer", Icons.Filled.Settings)
|
||||
data object RealtimeVoiceTest : Screen("settings/developer/realtime_voice", "Realtime voice", Icons.Filled.Settings)
|
||||
data object About : Screen("settings/about", "About", Icons.Filled.Settings)
|
||||
@@ -395,6 +398,7 @@ fun RelayApp() {
|
||||
val chatApiClient by connectionViewModel.chatApiClient.collectAsState()
|
||||
val lastSessionId by connectionViewModel.lastSessionId.collectAsState()
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
@@ -646,6 +650,11 @@ fun RelayApp() {
|
||||
chatViewModel.setProfileMessageLoader { sessionId ->
|
||||
connectionViewModel.loadProfileScopedMessages(sessionId)
|
||||
}
|
||||
// …and delete from that same profile's DB so a non-default profile's
|
||||
// session can't be resurrected by the next profile-scoped list.
|
||||
chatViewModel.profileSessionDeleter = { sessionId ->
|
||||
connectionViewModel.deleteProfileScopedSession(sessionId)
|
||||
}
|
||||
|
||||
// Wire session persistence callback
|
||||
chatViewModel.onSessionChanged = { sessionId ->
|
||||
@@ -660,15 +669,29 @@ fun RelayApp() {
|
||||
// refreshSessions() that would flash/reload the chat. `switchProfileContext`
|
||||
// already no-ops when the context key + session are unchanged.
|
||||
val chatClientReady = chatApiClient != null
|
||||
LaunchedEffect(chatClientReady, activeConnectionId, selectedProfile?.name, lastSessionId) {
|
||||
LaunchedEffect(chatClientReady, activeConnectionId, selectedProfile?.name, lastSessionId, profileSelectionSettled) {
|
||||
if (!chatClientReady) return@LaunchedEffect
|
||||
// Coalesce the rapid lastSessionId null→value churn a profile switch
|
||||
// produces: selectProfile() nulls lastSessionId, then the persisted
|
||||
// per-profile session resolves a tick later. This effect re-fires on that
|
||||
// change, cancelling the delay below before it commits — so we skip
|
||||
// painting the intermediate empty draft and land straight on the resolved
|
||||
// session (or a genuine fresh draft when the profile has no history).
|
||||
delay(160)
|
||||
// Cold-start profile-isolation guard: hold the first profile-scoped load
|
||||
// until the persisted profile selection has SETTLED, so the session
|
||||
// drawer (and the restored session context) don't briefly load the
|
||||
// SERVER-DEFAULT profile and then visibly snap to the real one. While a
|
||||
// non-default profile is still resolving we wait on a backstop instead of
|
||||
// fetching now; this effect re-fires the instant the profile resolves
|
||||
// (selectedProfile / profileSelectionSettled change), cancelling the wait
|
||||
// so only the correct, profile-scoped load lands. The backstop guarantees
|
||||
// the drawer is never permanently empty if the profile list never lands.
|
||||
if (!profileSelectionSettled) {
|
||||
delay(2_500L)
|
||||
} else {
|
||||
// Coalesce the rapid lastSessionId null→value churn a profile switch
|
||||
// produces: selectProfile() nulls lastSessionId, then the persisted
|
||||
// per-profile session resolves a tick later. This effect re-fires on
|
||||
// that change, cancelling the delay below before it commits — so we
|
||||
// skip painting the intermediate empty draft and land straight on the
|
||||
// resolved session (or a genuine fresh draft when the profile has no
|
||||
// history).
|
||||
delay(160)
|
||||
}
|
||||
chatViewModel.switchProfileContext(
|
||||
contextKey = AgentDisplay.profileContextKey(
|
||||
connectionId = activeConnectionId,
|
||||
@@ -940,6 +963,7 @@ fun RelayApp() {
|
||||
val relayReady by connectionViewModel.relayReady.collectAsState()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
|
||||
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
|
||||
val serverModelName by chatViewModel.serverModelName.collectAsState()
|
||||
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
|
||||
val appReady by connectionViewModel.isReady.collectAsState()
|
||||
@@ -1388,6 +1412,11 @@ fun RelayApp() {
|
||||
// Connections — preserves the affordance the dropped
|
||||
// header endpoint chip used to provide.
|
||||
onClick = openConnections,
|
||||
securityGlyph = if (transportStatus.tier != ChatTransportTier.Offline) {
|
||||
{ ConnectionSecurityGlyph(connectionSecurity) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1740,6 +1769,9 @@ fun RelayApp() {
|
||||
onNavigateToAnalytics = {
|
||||
navController.navigate(Screen.Analytics.route)
|
||||
},
|
||||
onNavigateToDiagnostics = {
|
||||
navController.navigate(Screen.Diagnostics.route)
|
||||
},
|
||||
onNavigateToVoiceSettings = {
|
||||
navController.navigate(Screen.VoiceSettings.route)
|
||||
},
|
||||
@@ -2068,6 +2100,12 @@ fun RelayApp() {
|
||||
chatViewModel = chatViewModel,
|
||||
)
|
||||
}
|
||||
composable(Screen.Diagnostics.route) {
|
||||
DiagnosticsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Screen.DeveloperSettings.route) {
|
||||
DeveloperSettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
|
||||
+24
-75
@@ -101,7 +101,7 @@ import kotlinx.coroutines.launch
|
||||
*/
|
||||
|
||||
/**
|
||||
* Standard Hermes status rows (API / Dashboard). Dashboard auth is surfaced
|
||||
* Hermes status rows (API / Dashboard). Dashboard auth is surfaced
|
||||
* here so users do not have to open Manage just to discover sign-in is needed.
|
||||
*/
|
||||
@Composable
|
||||
@@ -116,6 +116,15 @@ fun ActiveCardStandardStatusSection(
|
||||
val dashboardStatus = activeConnection?.dashboardLastStatus
|
||||
val dashboardSignInRequired =
|
||||
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
||||
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
|
||||
|
||||
// At-a-glance security rollup, promoted out of the Advanced fold. Tap for
|
||||
// the per-surface breakdown. Single source of truth: ConnectionSecurity.
|
||||
ConnectionSecurityBadgeWithSheet(
|
||||
security = connectionSecurity,
|
||||
size = TransportSecuritySize.Row,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "API Server",
|
||||
@@ -330,7 +339,7 @@ fun ActiveCardFeaturesSection(
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) {
|
||||
CapabilityRow(
|
||||
label = "Vanilla Hermes API",
|
||||
label = "Hermes API",
|
||||
value = apiValue,
|
||||
tone = apiTone,
|
||||
onClick = onOpenApiInfo,
|
||||
@@ -344,7 +353,7 @@ fun ActiveCardFeaturesSection(
|
||||
)
|
||||
CapabilityDivider()
|
||||
CapabilityRow(
|
||||
label = "Vanilla Hermes voice",
|
||||
label = "Hermes voice",
|
||||
value = voiceValue,
|
||||
tone = voiceTone,
|
||||
onClick = if (standardVoiceAvailability ==
|
||||
@@ -623,7 +632,7 @@ private fun ManualUrlSubsection(
|
||||
when {
|
||||
result.apiReachable && result.voiceConfigReachable ->
|
||||
if (result.voiceRoute == "standard") {
|
||||
"API and standard voice reachable"
|
||||
"API and Hermes voice reachable"
|
||||
} else {
|
||||
"API and relay voice reachable"
|
||||
}
|
||||
@@ -665,7 +674,7 @@ private fun ManualUrlSubsection(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "Relay is optional for voice. Vanilla Hermes voice uses the Hermes API; Relay voice uses this route when selected or needed.",
|
||||
text = "Relay is optional for voice. Hermes voice uses the Hermes API; Relay voice uses this route when selected or needed.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -710,7 +719,7 @@ private fun ManualUrlSubsection(
|
||||
Text(
|
||||
text = if (result.voiceConfigReachable) {
|
||||
if (result.voiceRoute == "standard") {
|
||||
"Voice ready via standard Hermes API"
|
||||
"Voice ready via Hermes API"
|
||||
} else {
|
||||
"Voice ready via ${result.relayUrl ?: "relay"}"
|
||||
}
|
||||
@@ -1110,56 +1119,19 @@ fun ActiveCardSecurityPosture(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onNavigateToPairedDevices: () -> Unit,
|
||||
) {
|
||||
val relayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
val effectiveApiServerUrl by connectionViewModel.effectiveApiServerUrl.collectAsState()
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
val effectiveRelayUrl by connectionViewModel.effectiveRelayUrl.collectAsState()
|
||||
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
|
||||
val insecureReason by connectionViewModel.insecureReason.collectAsState()
|
||||
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
|
||||
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
|
||||
val currentPairedSession by connectionViewModel.currentPairedSession.collectAsState()
|
||||
val pairedDevices by connectionViewModel.pairedDevices.collectAsState()
|
||||
// ADR 24 — surface the live endpoint role so the insecure badge can
|
||||
// say "Plain (on LAN)" instead of "Insecure (network unknown)" when
|
||||
// the resolver already knows which candidate we're on.
|
||||
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
|
||||
val selectedRouteUrls = buildList {
|
||||
effectiveApiServerUrl.trim().takeIf { it.isNotBlank() }?.let(::add)
|
||||
effectiveDashboardUrl.trim().takeIf { it.isNotBlank() }?.let(::add)
|
||||
val selectedRelayUrl = effectiveRelayUrl.ifBlank { relayUrl }
|
||||
if (relayConfigured || selectedRelayUrl.isNotBlank()) {
|
||||
selectedRelayUrl.trim().takeIf { it.isNotBlank() }?.let(::add)
|
||||
}
|
||||
}
|
||||
val secureUrlCount = selectedRouteUrls.count { url ->
|
||||
isSelectedRouteUrlSecure(
|
||||
url = url,
|
||||
activeEndpoint = activeEndpoint,
|
||||
isTailscaleDetected = isTailscaleDetected,
|
||||
)
|
||||
}
|
||||
val transportState = when {
|
||||
selectedRouteUrls.isEmpty() -> null
|
||||
secureUrlCount == selectedRouteUrls.size -> TransportSecurityState.AllSecure
|
||||
secureUrlCount > 0 -> TransportSecurityState.Mixed
|
||||
else -> TransportSecurityState.AllInsecure
|
||||
}
|
||||
|
||||
if (transportState != null) {
|
||||
TransportSecurityBadge(
|
||||
state = transportState,
|
||||
size = TransportSecuritySize.Row,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
TransportSecurityBadge(
|
||||
isSecure = isUrlSecure(relayUrl),
|
||||
reason = insecureReason.ifBlank { null },
|
||||
size = TransportSecuritySize.Row,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
activeRole = activeEndpoint?.role,
|
||||
)
|
||||
}
|
||||
// Connection-level security rollup (single source of truth —
|
||||
// ConnectionSecurity). Tap for the per-surface breakdown + the
|
||||
// mechanism explainer (TLS vs Tailscale/WireGuard vs plain).
|
||||
ConnectionSecurityBadgeWithSheet(
|
||||
security = connectionSecurity,
|
||||
size = TransportSecuritySize.Row,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (isTailscaleDetected) {
|
||||
Row(
|
||||
@@ -1230,29 +1202,6 @@ fun ActiveCardSecurityPosture(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSelectedRouteUrlSecure(
|
||||
url: String,
|
||||
activeEndpoint: EndpointCandidate?,
|
||||
isTailscaleDetected: Boolean,
|
||||
): Boolean {
|
||||
if (isUrlSecure(url)) return true
|
||||
return activeEndpoint.isEncryptedOverlayRoute(isTailscaleDetected)
|
||||
}
|
||||
|
||||
private fun EndpointCandidate?.isEncryptedOverlayRoute(isTailscaleDetected: Boolean): Boolean {
|
||||
if (this == null) return false
|
||||
val role = role.lowercase()
|
||||
val securityHint = security.orEmpty().lowercase()
|
||||
return role == "tailscale" ||
|
||||
(isTailscaleDetected && securityHint.contains("tailscale")) ||
|
||||
role == "plugin_proxy" ||
|
||||
role == "plugin-proxy" ||
|
||||
hasSecureProxy() ||
|
||||
securityHint.contains("wireguard") ||
|
||||
securityHint.contains("https") ||
|
||||
securityHint.contains("tls")
|
||||
}
|
||||
|
||||
/**
|
||||
* Numbered step row for the Manual pairing code fallback. Tightly
|
||||
* coupled to its Card 3 layout — step badge sizing + content shape —
|
||||
|
||||
@@ -17,8 +17,10 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
@@ -57,6 +59,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
@@ -85,8 +88,8 @@ import kotlinx.coroutines.delay
|
||||
* visual line so the bounded buffer maps cleanly to "≤6 lines". */
|
||||
private const val FLOW_MAX_CHARS = 42
|
||||
|
||||
/** Soft-wrap target only — the visible buffer is now bounded by the ~1/3
|
||||
* screen viewport + scroll, not a hard line count. */
|
||||
/** Soft-wrap target only — the visible buffer is now bounded by the
|
||||
* scrollable viewport height + scroll, not a hard line count. */
|
||||
private const val FLOW_MAX_LINES = 6
|
||||
|
||||
/** Memory ceiling for the persistent line buffer. Lines past this (already
|
||||
@@ -276,7 +279,9 @@ fun AgentTextFlow(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.semantics { liveRegion = LiveRegionMode.Polite }
|
||||
.topFadeEdge()
|
||||
// Fade the top edge ONLY when there's content scrolled above it —
|
||||
// a message that fits shows its first line crisply (no cut-off look).
|
||||
.topFadeEdge(fade = if (staticScroll.canScrollBackward) 28.dp else 0.dp)
|
||||
.verticalScroll(staticScroll),
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
) {
|
||||
@@ -297,41 +302,38 @@ fun AgentTextFlow(
|
||||
// --- Animated path ----------------------------------------------------
|
||||
val flowLines = remember(messageId) { mutableStateListOf<FlowLine>() }
|
||||
val currentContent by rememberUpdatedState(content)
|
||||
val currentStreaming by rememberUpdatedState(streaming)
|
||||
|
||||
LaunchedEffect(messageId) {
|
||||
flowLines.clear()
|
||||
// Largest segment index ever materialized — guards against re-adding a
|
||||
// line that was dropped from the front by the memory cap.
|
||||
var maxKeyAdded = -1
|
||||
var lastText: String? = null
|
||||
while (true) {
|
||||
val text = currentContent
|
||||
val isStreamingNow = currentStreaming
|
||||
val segs = segmentFlowLines(text, FLOW_MAX_CHARS)
|
||||
|
||||
// Add new lines (they slide in) and grow the still-streaming tail.
|
||||
// Lines PERSIST — they never fade out; older ones simply scroll up
|
||||
// within the bounded ~1/3-height viewport and dissolve at the top
|
||||
// fade edge. (No dwell / fade-out / removal anymore.)
|
||||
segs.forEachIndexed { i, s ->
|
||||
val existing = flowLines.firstOrNull { it.key == i }
|
||||
if (existing == null) {
|
||||
if (i > maxKeyAdded) {
|
||||
flowLines.add(FlowLine(key = i, initialText = s))
|
||||
maxKeyAdded = i
|
||||
// Re-diff only when the transcript changed, so an idle clean mode
|
||||
// (no streaming, no new turn) doesn't churn. We never permanently
|
||||
// exit: a new turn appended to the transcript must still slide in.
|
||||
if (text != lastText) {
|
||||
lastText = text
|
||||
val segs = segmentFlowLines(text, FLOW_MAX_CHARS)
|
||||
// Add new lines (they slide in); update a changed tail in place.
|
||||
// Lines PERSIST — older ones simply scroll up within the bounded,
|
||||
// scrollable viewport and dissolve at the top fade edge.
|
||||
segs.forEachIndexed { i, s ->
|
||||
val existing = flowLines.firstOrNull { it.key == i }
|
||||
if (existing == null) {
|
||||
if (i > maxKeyAdded) {
|
||||
flowLines.add(FlowLine(key = i, initialText = s))
|
||||
maxKeyAdded = i
|
||||
}
|
||||
} else if (existing.text != s) {
|
||||
existing.text = s
|
||||
}
|
||||
} else if (existing.text != s) {
|
||||
existing.text = s
|
||||
}
|
||||
// Memory guard: drop the oldest lines once well past the viewport.
|
||||
while (flowLines.size > FLOW_BUFFER_MAX) flowLines.removeAt(0)
|
||||
}
|
||||
|
||||
// Memory guard: drop the oldest lines once well past the viewport
|
||||
// (already scrolled above the fade — invisible to the user).
|
||||
while (flowLines.size > FLOW_BUFFER_MAX) flowLines.removeAt(0)
|
||||
|
||||
// Nothing left to do once the turn ended and every segment is in.
|
||||
if (!isStreamingNow && maxKeyAdded >= segs.lastIndex) return@LaunchedEffect
|
||||
|
||||
delay(FLOW_TICK_MS)
|
||||
}
|
||||
}
|
||||
@@ -362,7 +364,9 @@ fun AgentTextFlow(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.topFadeEdge()
|
||||
// Fade the top edge ONLY when content is scrolled above it, so a
|
||||
// reply that fits the viewport shows its first line crisply.
|
||||
.topFadeEdge(fade = if (scrollState.canScrollBackward) 28.dp else 0.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
) {
|
||||
@@ -516,11 +520,30 @@ fun CleanChatMode(
|
||||
val lastAssistant = remember(messages) {
|
||||
messages.lastOrNull { it.role == MessageRole.ASSISTANT }
|
||||
}
|
||||
val flowContent = lastAssistant?.content.orEmpty()
|
||||
// Clean mode shows the recent CONVERSATION (not just the last reply) as one
|
||||
// faded, scrollable flow, so scrolling up brings history into view. The flow
|
||||
// is append-only across turns; user turns get a subtle "›" so the
|
||||
// back-and-forth stays legible. How far back it retains is bounded by the
|
||||
// flow's line buffer (FLOW_BUFFER_MAX).
|
||||
val flowContent = remember(messages) {
|
||||
messages
|
||||
.filter { it.role == MessageRole.USER || it.role == MessageRole.ASSISTANT }
|
||||
.joinToString("\n\n") { msg ->
|
||||
val body = msg.content.trim()
|
||||
if (msg.role == MessageRole.USER) "› $body" else body
|
||||
}
|
||||
}
|
||||
// Stable per-conversation key so the flow buffer accumulates across turns and
|
||||
// resets only on a new conversation (the oldest message's id changes).
|
||||
val conversationKey = messages.firstOrNull()?.id
|
||||
val flowStreaming = lastAssistant?.isStreaming == true && isStreaming
|
||||
// Cap the flow at ~1/3 of the screen so lines can slide up and accumulate
|
||||
// without ever climbing into / blocking the avatar above them.
|
||||
val maxFlowHeight = (LocalConfiguration.current.screenHeightDp * 0.34f).dp
|
||||
// The sphere + text are a vertically-centered group (equal spacers above and
|
||||
// below). The sphere is a fixed size so the group grows via the TEXT: a short
|
||||
// reply sits centered, and as the reply lengthens the centered group gets
|
||||
// taller — sliding the sphere up toward the top third while the text fills
|
||||
// down toward the composer.
|
||||
val sphereHeight = (LocalConfiguration.current.screenHeightDp * 0.34f).dp
|
||||
val maxFlowHeight = (LocalConfiguration.current.screenHeightDp * 0.5f).dp
|
||||
|
||||
BackHandler(enabled = true) { onExit() }
|
||||
|
||||
@@ -533,7 +556,19 @@ fun CleanChatMode(
|
||||
.fillMaxSize()
|
||||
// Opaque so the chat underneath is fully hidden — this is a mode,
|
||||
// not a translucent overlay.
|
||||
.background(RelayRefresh.Background),
|
||||
.background(RelayRefresh.Background)
|
||||
// Consume any pointer event the children (composer, exit button, text
|
||||
// scroll) didn't handle, so stray taps/swipes in the empty areas don't
|
||||
// fall through to the chat + session drawer behind this mode. Children
|
||||
// run leaf-first on the same Main pass, so this only catches the gaps
|
||||
// (mirrors the voice overlay's focus-mode scrim).
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent().changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -557,12 +592,17 @@ fun CleanChatMode(
|
||||
}
|
||||
}
|
||||
|
||||
// Centered sphere — takes the slack so the flow + composer keep a
|
||||
// stable bottom anchor as lines come and go.
|
||||
// Flexible top spacer — with the bottom one it vertically centers the
|
||||
// sphere + text group; as the text grows the spacers yield and the
|
||||
// sphere rises toward the top third.
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Bounded, centered sphere — a fixed size so the group grows via the
|
||||
// text, sliding the sphere upward as the conversation lengthens.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
.height(sphereHeight),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
@@ -586,8 +626,11 @@ fun CleanChatMode(
|
||||
AgentTextFlow(
|
||||
content = flowContent,
|
||||
streaming = flowStreaming,
|
||||
messageId = lastAssistant?.id,
|
||||
messageId = conversationKey,
|
||||
motionEnabled = textMotionEnabled,
|
||||
// Content-sized reading area (capped ~half the screen) directly
|
||||
// below the sphere — no gap between them. Grows + scrolls with the
|
||||
// reply, which is what lifts the centered group (and the sphere).
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 560.dp)
|
||||
@@ -595,6 +638,10 @@ fun CleanChatMode(
|
||||
.padding(bottom = 12.dp),
|
||||
)
|
||||
|
||||
// Flexible bottom spacer — balances the top one to keep the
|
||||
// sphere + text group vertically centered.
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
CleanModeComposer(
|
||||
enabled = enabled,
|
||||
onSend = onSend,
|
||||
|
||||
@@ -1480,7 +1480,7 @@ fun AgentInfoSheet(
|
||||
val hostname = com.hermesandroid.relay.data.Connection
|
||||
.extractDefaultLabel(connection.apiServerUrl)
|
||||
val statusLine = when {
|
||||
connection.pairedAt == null -> "$hostname • Vanilla Hermes"
|
||||
connection.pairedAt == null -> "$hostname • Hermes"
|
||||
else -> "$hostname • Paired"
|
||||
}
|
||||
ProfileRadioRow(
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ConnectionSecurity
|
||||
import com.hermesandroid.relay.data.ConnectionSecurityLevel
|
||||
import com.hermesandroid.relay.data.SurfaceSecurity
|
||||
|
||||
private const val LEARN_MORE_URL =
|
||||
"https://codename-11.github.io/hermes-relay/architecture/connection-security.html"
|
||||
|
||||
/**
|
||||
* Per-surface "Connection security" detail sheet — the tap target for the
|
||||
* connection-security badge. Shows the rollup, the per-transport breakdown,
|
||||
* and a one-line explainer of the mechanism so the at-a-glance badge never
|
||||
* has to lie about a mixed connection.
|
||||
*/
|
||||
/**
|
||||
* Self-contained badge that opens the [ConnectionSecuritySheet] on tap. Drop
|
||||
* it on any surface (connection header, posture strip) without threading sheet
|
||||
* state through the caller.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionSecurityBadgeWithSheet(
|
||||
security: ConnectionSecurity,
|
||||
modifier: Modifier = Modifier,
|
||||
size: TransportSecuritySize = TransportSecuritySize.Chip,
|
||||
) {
|
||||
var show by remember { mutableStateOf(false) }
|
||||
ConnectionSecurityBadge(
|
||||
security = security,
|
||||
modifier = modifier,
|
||||
size = size,
|
||||
onClick = { show = true },
|
||||
)
|
||||
if (show) {
|
||||
ConnectionSecuritySheet(security = security, onDismiss = { show = false })
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConnectionSecuritySheet(
|
||||
security: ConnectionSecurity,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Connection security",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
ConnectionSecurityBadge(
|
||||
security = security,
|
||||
size = TransportSecuritySize.Large,
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
if (security.surfaces.isEmpty()) {
|
||||
Text(
|
||||
text = "No active route yet. Connect to a server to see how each " +
|
||||
"part of the connection is protected.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
security.surfaces.forEach { SurfaceSecurityRow(it) }
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
text = explainer(security.level),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
TextButton(onClick = { uriHandler.openUri(LEARN_MORE_URL) }) {
|
||||
Text("Learn about connection security →")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SurfaceSecurityRow(surface: SurfaceSecurity) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SurfaceSecurityGlyph(kind = surface.kind, modifier = Modifier.size(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = surface.label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = surface.url,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = surface.mechanism,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun explainer(level: ConnectionSecurityLevel): String = when (level) {
|
||||
ConnectionSecurityLevel.Tls ->
|
||||
"Encrypted with TLS. The server's certificate is pinned on first connect."
|
||||
ConnectionSecurityLevel.Overlay ->
|
||||
"Encrypted by your overlay network (e.g. Tailscale/WireGuard), not TLS. " +
|
||||
"Cert pinning applies only to TLS routes."
|
||||
ConnectionSecurityLevel.Mixed ->
|
||||
"Some parts of this connection are encrypted and some are plain. The app " +
|
||||
"prefers a secure route when one is reachable."
|
||||
ConnectionSecurityLevel.Plain ->
|
||||
"Not encrypted. Only safe on a network you fully trust — anyone in between " +
|
||||
"could read this traffic."
|
||||
ConnectionSecurityLevel.Unknown -> ""
|
||||
}
|
||||
+1
-1
@@ -119,7 +119,7 @@ private fun ConnectionRow(
|
||||
) {
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val statusLine = if (connection.pairedAt == null) {
|
||||
"$hostname • Vanilla Hermes"
|
||||
"$hostname • Hermes"
|
||||
} else {
|
||||
"$hostname • Paired"
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ import kotlinx.coroutines.withTimeout
|
||||
|
||||
/**
|
||||
* Shared connection wizard used by both onboarding (first run) and
|
||||
* Settings → Connections. Standard Hermes setup is the default path:
|
||||
* Settings → Connections. Hermes setup is the default path:
|
||||
* save the API URL/key, derive the dashboard URL, and verify sessions.
|
||||
* Relay pairing remains available for power tools such as Terminal,
|
||||
* Bridge, Relay sessions, channel grants, and relay-backed media routes.
|
||||
@@ -107,7 +107,7 @@ import kotlinx.coroutines.withTimeout
|
||||
* Steps:
|
||||
*
|
||||
* 1. **Method** — pick a setup path. Four tiles:
|
||||
* - **Standard Hermes**: API URL + API key. → StandardEntry.
|
||||
* - **Hermes**: API URL + API key. → StandardEntry.
|
||||
* - **Scan QR**: standard convenience path for API URL/key QRs; Relay
|
||||
* plugin QRs still work and route through Confirm/Relay pair.
|
||||
* - **Pair Relay by code**: server already minted a code via
|
||||
@@ -1013,7 +1013,7 @@ private fun MethodStep(
|
||||
|
||||
MethodTile(
|
||||
icon = Icons.Filled.Check,
|
||||
title = "Vanilla Hermes",
|
||||
title = "Hermes",
|
||||
subtitle = "API/dashboard setup for Chat, Manage, Skills, Cron, MCP, Profiles, Models, and Settings",
|
||||
onClick = onPickStandard,
|
||||
isPrimary = true,
|
||||
@@ -1022,7 +1022,7 @@ private fun MethodStep(
|
||||
MethodTile(
|
||||
icon = Icons.Filled.QrCodeScanner,
|
||||
title = "Scan setup QR",
|
||||
subtitle = "Scan a QR with API URL/key for Standard; Relay QR details require the Hermes-Relay plugin",
|
||||
subtitle = "Scan a QR with API URL/key for Hermes; Relay QR details require the Relay plugin",
|
||||
onClick = onPickScan,
|
||||
)
|
||||
|
||||
@@ -1039,7 +1039,7 @@ private fun MethodStep(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = "Terminal, Bridge, Relay sessions, and grants require the Hermes-Relay plugin.",
|
||||
text = "Terminal, Bridge, Relay sessions, and grants require the Relay plugin.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -1251,7 +1251,7 @@ private fun StandardEntryStep(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "Vanilla Hermes",
|
||||
text = "Hermes",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
Text(
|
||||
@@ -1617,7 +1617,7 @@ private fun StandardSetupResultCard(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Vanilla Hermes connected",
|
||||
text = "Hermes connected",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
ReadinessLine(
|
||||
@@ -2481,7 +2481,7 @@ private fun ConfirmStep(
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Connecting to Vanilla Hermes",
|
||||
text = "Connecting to Hermes",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
|
||||
@@ -48,8 +48,11 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.SurfaceSecurityKind
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.data.isEncryptedOverlayRoute
|
||||
import com.hermesandroid.relay.data.isKnownRole
|
||||
import com.hermesandroid.relay.data.isTlsUrl
|
||||
import com.hermesandroid.relay.network.shared.RouteProbeOutcome
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -241,6 +244,7 @@ private fun EndpointRow(
|
||||
text = candidate.displayLabel(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
SurfaceSecurityGlyph(kind = candidate.routeSecurityKind())
|
||||
if (isActive) {
|
||||
ActiveChip()
|
||||
} else if (isPreferred) {
|
||||
@@ -498,6 +502,18 @@ private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
|
||||
else -> Icons.Filled.Shield
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-route security classification for the picker glyph. Keyed on the
|
||||
* candidate's own scheme + role (no device-level Tailscale detection needed —
|
||||
* a `tailscale`/`plugin_proxy` role is encrypted regardless), so each row can
|
||||
* be classified independently before it's the active route.
|
||||
*/
|
||||
private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
|
||||
isTlsUrl(api.url) -> SurfaceSecurityKind.Tls
|
||||
isEncryptedOverlayRoute(isTailscaleDetected = false) -> SurfaceSecurityKind.Overlay
|
||||
else -> SurfaceSecurityKind.Plain
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/edit dialog for an extra fallback route — the manual counterpart of a
|
||||
* v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can
|
||||
@@ -601,7 +617,7 @@ fun RouteEditorDialog(
|
||||
errorText = null
|
||||
},
|
||||
label = { Text("API server URL or host") },
|
||||
placeholder = { Text("100.71.8.56 or http://host:8642") },
|
||||
placeholder = { Text("100.64.0.1 or http://host:8642") },
|
||||
singleLine = true,
|
||||
isError = errorText != null,
|
||||
supportingText = {
|
||||
|
||||
@@ -40,9 +40,8 @@ enum class PowerFeatureGateStatus(
|
||||
RequiresPairing(
|
||||
label = "Requires pairing",
|
||||
actionLabel = "Pair to unlock",
|
||||
explanation = "This feature runs over the Hermes Relay plugin. Make sure the Relay " +
|
||||
"plugin is installed and running on your Hermes server, then pair this device " +
|
||||
"to unlock it.",
|
||||
explanation = "This feature requires the Relay plugin. Make sure it is installed " +
|
||||
"and running on your Hermes server, then pair this device to unlock it.",
|
||||
),
|
||||
PairingExpired(
|
||||
label = "Pairing expired",
|
||||
|
||||
@@ -87,11 +87,11 @@ import kotlin.math.max
|
||||
* ```json
|
||||
* {
|
||||
* "hermes": 1,
|
||||
* "host": "172.16.24.250",
|
||||
* "host": "192.168.1.100",
|
||||
* "port": 8642,
|
||||
* "key": "bearer-token",
|
||||
* "tls": false,
|
||||
* "relay": { "url": "ws://172.16.24.250:8767", "code": "ABCD12" }
|
||||
* "relay": { "url": "ws://192.168.1.100:8767", "code": "ABCD12" }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
@@ -187,7 +187,7 @@ data class HermesPairingPayload(
|
||||
* Relay connection details carried in a Hermes pairing QR.
|
||||
*
|
||||
* - [url] is the full WebSocket URL the phone should connect to, e.g.
|
||||
* `ws://172.16.24.250:8767` for dev or `wss://relay.example.com:8767`
|
||||
* `ws://192.168.1.100:8767` for dev or `wss://relay.example.com:8767`
|
||||
* for a TLS-fronted relay.
|
||||
* - [code] is a 6-char one-shot pairing code that the relay has already
|
||||
* registered via its localhost-only `/pairing/register` endpoint. The
|
||||
@@ -799,7 +799,7 @@ fun QrPairingScanner(
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = "Ask Hermes: \"Generate a QR code with my API URL and API key.\" Relay pairing QRs require the Hermes-Relay plugin.",
|
||||
text = "Ask Hermes: \"Generate a QR code with my API URL and API key.\" Relay pairing QRs require the Relay plugin.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
|
||||
@@ -33,6 +33,8 @@ fun RelayStatusStrip(
|
||||
trailing: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
/** Optional security marker rendered just before the route label. */
|
||||
securityGlyph: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
@@ -65,6 +67,9 @@ fun RelayStatusStrip(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
leadingBadge()
|
||||
if (securityGlyph != null) {
|
||||
securityGlyph()
|
||||
}
|
||||
if (routeLabel.isNotBlank()) {
|
||||
Text(
|
||||
text = "· $routeLabel",
|
||||
|
||||
@@ -79,7 +79,7 @@ fun StatsForNerds(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Analytics",
|
||||
text = "Overview",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
@@ -106,10 +106,10 @@ fun StatsForNerds(
|
||||
val tokensPerMsg = if (appStats.totalMessagesSent > 0)
|
||||
totalTokens / appStats.totalMessagesSent else 0L
|
||||
Text(
|
||||
text = "${appStats.totalMessagesSent} messages | " +
|
||||
text = "${appStats.totalMessagesSent} messages · " +
|
||||
"${formatTokenCount(totalTokens)} tokens" +
|
||||
(if (tokensPerMsg > 0) " (~${formatTokenCount(tokensPerMsg)}/msg)" else "") +
|
||||
" | ${appStats.sessionCount} sessions",
|
||||
" · ${appStats.sessionCount} sessions",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
@@ -8,8 +8,10 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -33,12 +35,16 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ToolCallEvent
|
||||
import com.hermesandroid.relay.diagnostics.CheckStatus
|
||||
import com.hermesandroid.relay.diagnostics.StatusCheck
|
||||
import com.hermesandroid.relay.viewmodel.VoiceStats
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -181,6 +187,234 @@ private fun LegendEntry(label: String, color: Color) {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Status-check timeline (Diagnostics)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Vertical timeline of derived [StatusCheck]s for the Diagnostics screen.
|
||||
*
|
||||
* Shares the dot + colour-legend visual language of [TimelineView] above, but
|
||||
* adds a connecting rail between dots and renders each check's failure
|
||||
* [StatusCheck.reason] inline — the whole point of the screen. Rows whose check
|
||||
* carries a concrete log entry ([StatusCheck.timestampMs] != null) are tappable
|
||||
* so the host can open the full diagnostic detail.
|
||||
*/
|
||||
@Composable
|
||||
fun StatusCheckTimeline(
|
||||
checks: List<StatusCheck>,
|
||||
modifier: Modifier = Modifier,
|
||||
onCheckClick: (StatusCheck) -> Unit = {},
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Status checks",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = statusSummary(checks),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
StatusCheckLegend()
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
if (checks.isEmpty()) {
|
||||
Text(
|
||||
text = "No checks yet — connect to a server to populate diagnostics.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
checks.forEachIndexed { index, check ->
|
||||
StatusCheckRow(
|
||||
check = check,
|
||||
isFirst = index == 0,
|
||||
isLast = index == checks.lastIndex,
|
||||
onClick = { onCheckClick(check) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusCheckLegend() {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
LegendEntry("Pass", CheckStatus.Pass.statusColor())
|
||||
LegendEntry("Warn", CheckStatus.Warn.statusColor())
|
||||
LegendEntry("Fail", CheckStatus.Fail.statusColor())
|
||||
LegendEntry("Unknown", CheckStatus.Unknown.statusColor())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusCheckRow(
|
||||
check: StatusCheck,
|
||||
isFirst: Boolean,
|
||||
isLast: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dotColor = check.status.statusColor()
|
||||
val railColor = MaterialTheme.colorScheme.outlineVariant
|
||||
// Only rows backed by a concrete log entry (timestamp captured) open a
|
||||
// deep-detail view — keeps the "tap for detail" affordance honest.
|
||||
val hasDetail = check.timestampMs != null
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.then(if (hasDetail) Modifier.clickable(onClick = onClick) else Modifier),
|
||||
) {
|
||||
// Rail gutter: a vertical connecting line through the column with the
|
||||
// status dot punched over it. Drawn in a draw-scope so dp→px and the
|
||||
// first/last segment trimming stay self-contained.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(22.dp)
|
||||
.drawBehind {
|
||||
val cx = size.width / 2f
|
||||
val dotCenterY = 12.dp.toPx()
|
||||
val dotRadius = 5.dp.toPx()
|
||||
val lineWidth = 2.dp.toPx()
|
||||
if (!isFirst) {
|
||||
drawLine(
|
||||
color = railColor,
|
||||
start = Offset(cx, 0f),
|
||||
end = Offset(cx, dotCenterY),
|
||||
strokeWidth = lineWidth,
|
||||
)
|
||||
}
|
||||
if (!isLast) {
|
||||
drawLine(
|
||||
color = railColor,
|
||||
start = Offset(cx, dotCenterY),
|
||||
end = Offset(cx, size.height),
|
||||
strokeWidth = lineWidth,
|
||||
)
|
||||
}
|
||||
drawCircle(
|
||||
color = dotColor,
|
||||
radius = dotRadius,
|
||||
center = Offset(cx, dotCenterY),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 4.dp, bottom = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = check.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatusPill(check.status)
|
||||
}
|
||||
|
||||
check.reason?.let { reason ->
|
||||
Text(
|
||||
text = reason,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (check.status == CheckStatus.Fail) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (hasDetail) {
|
||||
Text(
|
||||
text = "Tap for log detail",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusPill(status: CheckStatus) {
|
||||
val color = status.statusColor()
|
||||
val label = when (status) {
|
||||
CheckStatus.Pass -> "PASS"
|
||||
CheckStatus.Warn -> "WARN"
|
||||
CheckStatus.Fail -> "FAIL"
|
||||
CheckStatus.Unknown -> "UNKNOWN"
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = color.copy(alpha = 0.16f),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = color,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One-line "N failing · N warning · N passing" summary for the header. */
|
||||
private fun statusSummary(checks: List<StatusCheck>): String {
|
||||
if (checks.isEmpty()) return "no checks"
|
||||
val fail = checks.count { it.status == CheckStatus.Fail }
|
||||
val warn = checks.count { it.status == CheckStatus.Warn }
|
||||
val pass = checks.count { it.status == CheckStatus.Pass }
|
||||
return buildList {
|
||||
if (fail > 0) add("$fail failing")
|
||||
if (warn > 0) add("$warn warning")
|
||||
add("$pass passing")
|
||||
}.joinToString(" · ")
|
||||
}
|
||||
|
||||
/** Dot/pill colour per [CheckStatus]: green / amber / error-red / gray. */
|
||||
@Composable
|
||||
private fun CheckStatus.statusColor(): Color = when (this) {
|
||||
CheckStatus.Pass -> Color(0xFF4CAF50)
|
||||
CheckStatus.Warn -> Color(0xFFFFB300)
|
||||
CheckStatus.Fail -> MaterialTheme.colorScheme.error
|
||||
CheckStatus.Unknown -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimelineRow(
|
||||
bucket: TimelineBucket,
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -22,6 +23,9 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ConnectionSecurity
|
||||
import com.hermesandroid.relay.data.ConnectionSecurityLevel
|
||||
import com.hermesandroid.relay.data.SurfaceSecurityKind
|
||||
|
||||
/**
|
||||
* Visual badge for the current relay transport security posture.
|
||||
@@ -294,3 +298,123 @@ fun isUrlSecure(url: String?): Boolean {
|
||||
val lower = url.trim().lowercase()
|
||||
return lower.startsWith("wss://") || lower.startsWith("https://")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConnectionSecurity-driven badge (single source of truth — see
|
||||
// data/ConnectionSecurity.kt). Mechanism-first copy: a Tailscale/WireGuard
|
||||
// route reads "Encrypted · Tailscale", NOT "Secure — TLS". Both TLS and
|
||||
// overlay are green; only true plaintext-without-overlay warns.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private data class ConnSecAppearance(
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
val bg: Color,
|
||||
val fg: Color,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun connSecAppearance(security: ConnectionSecurity): ConnSecAppearance {
|
||||
val green = Color(0xFF2E7D32)
|
||||
val amber = Color(0xFFF9A825)
|
||||
val red = MaterialTheme.colorScheme.error
|
||||
return when (security.level) {
|
||||
ConnectionSecurityLevel.Tls -> ConnSecAppearance(
|
||||
label = "Encrypted · TLS",
|
||||
icon = Icons.Filled.Lock,
|
||||
bg = green.copy(alpha = 0.14f),
|
||||
fg = green,
|
||||
)
|
||||
ConnectionSecurityLevel.Overlay -> ConnSecAppearance(
|
||||
label = "Encrypted · ${security.mechanism}",
|
||||
icon = Icons.Filled.Shield,
|
||||
bg = green.copy(alpha = 0.14f),
|
||||
fg = green,
|
||||
)
|
||||
ConnectionSecurityLevel.Mixed -> ConnSecAppearance(
|
||||
label = "Mixed routes",
|
||||
icon = Icons.Filled.Shield,
|
||||
bg = amber.copy(alpha = 0.16f),
|
||||
fg = amber,
|
||||
)
|
||||
ConnectionSecurityLevel.Plain -> ConnSecAppearance(
|
||||
label = if (security.mechanism.isNotBlank() && security.mechanism != "Plain") {
|
||||
"Not encrypted · ${security.mechanism}"
|
||||
} else {
|
||||
"Not encrypted"
|
||||
},
|
||||
icon = Icons.Filled.LockOpen,
|
||||
bg = red.copy(alpha = 0.16f),
|
||||
fg = red,
|
||||
)
|
||||
ConnectionSecurityLevel.Unknown -> ConnSecAppearance(
|
||||
label = "Checking…",
|
||||
icon = Icons.Filled.Shield,
|
||||
bg = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
fg = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The connection-level security badge every surface should use. Renders the
|
||||
* rollup from [ConnectionSecurity]; tap (when [onClick] is set) opens the
|
||||
* per-surface detail sheet. Renders nothing while the verdict is Unknown.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionSecurityBadge(
|
||||
security: ConnectionSecurity,
|
||||
modifier: Modifier = Modifier,
|
||||
size: TransportSecuritySize = TransportSecuritySize.Chip,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
if (security.level == ConnectionSecurityLevel.Unknown) return
|
||||
val a = connSecAppearance(security)
|
||||
RenderBadge(
|
||||
label = a.label,
|
||||
bg = a.bg,
|
||||
fg = a.fg,
|
||||
icon = a.icon,
|
||||
size = size,
|
||||
modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/** Icon-only security marker for tight spots (chat status strip). */
|
||||
@Composable
|
||||
fun ConnectionSecurityGlyph(
|
||||
security: ConnectionSecurity,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (security.level == ConnectionSecurityLevel.Unknown) return
|
||||
val a = connSecAppearance(security)
|
||||
Icon(
|
||||
imageVector = a.icon,
|
||||
contentDescription = a.label,
|
||||
tint = a.fg,
|
||||
modifier = modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Per-route security glyph for the route picker (one [SurfaceSecurityKind]). */
|
||||
@Composable
|
||||
fun SurfaceSecurityGlyph(
|
||||
kind: SurfaceSecurityKind,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val green = Color(0xFF2E7D32)
|
||||
val amber = Color(0xFFF9A825)
|
||||
val (icon, tint, desc) = when (kind) {
|
||||
SurfaceSecurityKind.Tls -> Triple(Icons.Filled.Lock, green, "Encrypted (TLS)")
|
||||
SurfaceSecurityKind.Overlay -> Triple(Icons.Filled.Shield, green, "Encrypted")
|
||||
// Per-route plaintext is amber (informational), not red — a secure
|
||||
// route may exist alongside it.
|
||||
SurfaceSecurityKind.Plain -> Triple(Icons.Filled.LockOpen, amber, "Not encrypted")
|
||||
}
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = desc,
|
||||
tint = tint,
|
||||
modifier = modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.components.StatsForNerds
|
||||
import com.hermesandroid.relay.ui.components.TimelineView
|
||||
@@ -70,14 +71,24 @@ fun AnalyticsScreen(
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
// Stats for Nerds section
|
||||
Text(
|
||||
text = "Stats for Nerds",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
// Section intro — names the grouping and clarifies these are local,
|
||||
// on-device metrics (distinct from the TopAppBar "Analytics" title
|
||||
// and each card's own header below).
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(
|
||||
text = "Stats for Nerds",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Local, on-device performance and usage metrics.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
StatsForNerds(
|
||||
voiceStats = voiceStats,
|
||||
|
||||
@@ -1852,7 +1852,7 @@ fun ChatScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Chat needs a Vanilla Hermes API connection.",
|
||||
text = "Chat needs a Hermes API connection.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -1860,7 +1860,7 @@ fun ChatScreen(
|
||||
onClick = onNavigateToConnect,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Connect Vanilla Hermes")
|
||||
Text("Connect Hermes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ fun ConnectionsSettingsScreen(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Tap Add connection to connect to Vanilla Hermes.",
|
||||
text = "Tap Add connection to connect to Hermes.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -868,7 +868,7 @@ private fun ConnectionCard(
|
||||
SectionHeader(text = "Advanced")
|
||||
SectionCaption(
|
||||
text = "Manual setup — most people don't need this " +
|
||||
"after Vanilla Hermes setup.",
|
||||
"after Hermes setup.",
|
||||
)
|
||||
|
||||
// Advanced expander: manual URL config + insecure toggle
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.diagnostics.CheckStatus
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.diagnostics.StatusCheck
|
||||
import com.hermesandroid.relay.network.shared.ConnectivityObserver
|
||||
import com.hermesandroid.relay.network.upstream.ServerCapabilities
|
||||
import com.hermesandroid.relay.ui.components.DiagnosticDetailDialog
|
||||
import com.hermesandroid.relay.ui.components.DiagnosticsLogPanel
|
||||
import com.hermesandroid.relay.ui.components.StatusCheckTimeline
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
|
||||
/**
|
||||
* Dedicated Diagnostics screen — replaces the old modal bottom sheet. Hosts a
|
||||
* vertical timeline of subsystem **status checks** (with failure reasons) at
|
||||
* the top, then the existing "Recent diagnostics" activity log below it.
|
||||
*
|
||||
* The checks are derived **read-only** from the flows [ConnectionViewModel]
|
||||
* already exposes (network / API health, capability snapshot, auth + relay
|
||||
* readiness, voice readiness) plus the recent [DiagnosticsLog] — no new probing
|
||||
* is started here, so the screen stays an honest snapshot of current state.
|
||||
* A failing check whose reason came from a logged error is tappable and opens
|
||||
* that entry's full [DiagnosticDetailDialog].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DiagnosticsScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val network by connectionViewModel.networkStatus.collectAsState()
|
||||
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||
val apiUrl by connectionViewModel.apiServerUrl.collectAsState()
|
||||
val capabilities by connectionViewModel.serverCapabilities.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val chatReady by connectionViewModel.chatReady.collectAsState()
|
||||
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
|
||||
val relayHealth by connectionViewModel.relayServerHealth.collectAsState()
|
||||
val relayReady by connectionViewModel.relayReady.collectAsState()
|
||||
val voiceReady by connectionViewModel.voiceReady.collectAsState()
|
||||
val relayVoiceReady by connectionViewModel.relayVoiceReady.collectAsState()
|
||||
val entries by DiagnosticsLog.entries.collectAsState()
|
||||
|
||||
val checks = remember(
|
||||
network, apiHealth, apiUrl, capabilities, authState, chatReady,
|
||||
relayConfigured, relayHealth, relayReady, voiceReady, relayVoiceReady, entries,
|
||||
) {
|
||||
buildStatusChecks(
|
||||
network = network,
|
||||
apiHealth = apiHealth,
|
||||
apiUrl = apiUrl,
|
||||
capabilities = capabilities,
|
||||
authState = authState,
|
||||
chatReady = chatReady,
|
||||
relayConfigured = relayConfigured,
|
||||
relayHealth = relayHealth,
|
||||
relayReady = relayReady,
|
||||
voiceReady = voiceReady,
|
||||
relayVoiceReady = relayVoiceReady,
|
||||
recentEntries = entries,
|
||||
)
|
||||
}
|
||||
|
||||
// Tapping a check backed by a concrete log entry opens its full detail.
|
||||
var selectedEntry by remember { mutableStateOf<DiagnosticLogEntry?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Diagnostics") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Status",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
StatusCheckTimeline(
|
||||
checks = checks,
|
||||
onCheckClick = { check ->
|
||||
selectedEntry = entries.lastOrNull { entry ->
|
||||
check.category != null &&
|
||||
entry.category == check.category &&
|
||||
(check.timestampMs == null || entry.timestampMs == check.timestampMs)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Recent diagnostics",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Recent app-level connection and voice events. Secrets and raw " +
|
||||
"payloads are hidden.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
DiagnosticsLogPanel(
|
||||
title = "Activity log",
|
||||
limit = 80,
|
||||
showCategory = true,
|
||||
showClear = true,
|
||||
showSeverityFilter = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
selectedEntry?.let { entry ->
|
||||
DiagnosticDetailDialog(entry = entry, onDismiss = { selectedEntry = null })
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Read-only check derivation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Derive the status-check list from a snapshot of connection state + the recent
|
||||
* [DiagnosticsLog]. Pure and side-effect free (no probing) so it is trivially
|
||||
* testable and re-runs cheaply whenever any input flow emits.
|
||||
*
|
||||
* When a check fails or warns and a matching-category error sits in
|
||||
* [recentEntries], that entry's message becomes the reason and its timestamp is
|
||||
* stamped onto the check — which is what makes the row tappable for full detail.
|
||||
*/
|
||||
internal fun buildStatusChecks(
|
||||
network: ConnectivityObserver.Status,
|
||||
apiHealth: ConnectionViewModel.HealthStatus,
|
||||
apiUrl: String,
|
||||
capabilities: ServerCapabilities,
|
||||
authState: AuthState,
|
||||
chatReady: Boolean,
|
||||
relayConfigured: Boolean,
|
||||
relayHealth: ConnectionViewModel.HealthStatus,
|
||||
relayReady: Boolean,
|
||||
voiceReady: Boolean,
|
||||
relayVoiceReady: Boolean,
|
||||
recentEntries: List<DiagnosticLogEntry>,
|
||||
): List<StatusCheck> {
|
||||
// Most recent ERROR for a category (entries are oldest -> newest).
|
||||
fun recentError(category: DiagnosticCategory): DiagnosticLogEntry? =
|
||||
recentEntries.lastOrNull {
|
||||
it.category == category && it.severity == DiagnosticSeverity.Error
|
||||
}
|
||||
|
||||
fun DiagnosticLogEntry.message(): String = detail ?: title
|
||||
|
||||
val checks = mutableListOf<StatusCheck>()
|
||||
|
||||
// 1) Network reachability.
|
||||
checks += when (network) {
|
||||
ConnectivityObserver.Status.Available ->
|
||||
StatusCheck("Network", CheckStatus.Pass, reason = "Device is online")
|
||||
ConnectivityObserver.Status.Lost ->
|
||||
StatusCheck(
|
||||
"Network", CheckStatus.Fail,
|
||||
reason = "Network connection lost",
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
)
|
||||
ConnectivityObserver.Status.Unavailable ->
|
||||
StatusCheck(
|
||||
"Network", CheckStatus.Warn,
|
||||
reason = "No active network detected",
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
)
|
||||
}
|
||||
|
||||
// 2) API server reachability.
|
||||
val host = DiagnosticsLog.sanitizeUrl(apiUrl)
|
||||
val apiErr = recentError(DiagnosticCategory.Api)
|
||||
checks += when (apiHealth) {
|
||||
ConnectionViewModel.HealthStatus.Reachable ->
|
||||
StatusCheck(
|
||||
"API server", CheckStatus.Pass,
|
||||
reason = host?.let { "Reachable at $it" } ?: "Reachable",
|
||||
category = DiagnosticCategory.Api,
|
||||
)
|
||||
ConnectionViewModel.HealthStatus.Unreachable ->
|
||||
StatusCheck(
|
||||
"API server", CheckStatus.Fail,
|
||||
reason = apiErr?.message() ?: (host?.let { "Not reachable at $it" } ?: "Not reachable"),
|
||||
category = DiagnosticCategory.Api,
|
||||
timestampMs = apiErr?.timestampMs,
|
||||
)
|
||||
ConnectionViewModel.HealthStatus.Probing ->
|
||||
StatusCheck(
|
||||
"API server", CheckStatus.Unknown,
|
||||
reason = "Probing…",
|
||||
category = DiagnosticCategory.Api,
|
||||
)
|
||||
ConnectionViewModel.HealthStatus.Unknown ->
|
||||
StatusCheck(
|
||||
"API server", CheckStatus.Unknown,
|
||||
reason = "Not checked yet",
|
||||
category = DiagnosticCategory.Api,
|
||||
)
|
||||
}
|
||||
|
||||
// 3) Server capabilities (which chat surfaces the server advertises).
|
||||
checks += when {
|
||||
!capabilities.healthy ->
|
||||
StatusCheck(
|
||||
"Server capabilities", CheckStatus.Unknown,
|
||||
reason = "Not probed — no healthy server yet",
|
||||
category = DiagnosticCategory.Api,
|
||||
)
|
||||
capabilities.sessionsChatStream ->
|
||||
StatusCheck(
|
||||
"Server capabilities", CheckStatus.Pass,
|
||||
reason = "Native session streaming available",
|
||||
category = DiagnosticCategory.Api,
|
||||
)
|
||||
capabilities.sessionsApi || capabilities.runs || capabilities.portable ->
|
||||
StatusCheck(
|
||||
"Server capabilities", CheckStatus.Warn,
|
||||
reason = "No session SSE — falling back to ${capabilities.preferredChatEndpoint()}",
|
||||
category = DiagnosticCategory.Api,
|
||||
)
|
||||
else ->
|
||||
StatusCheck(
|
||||
"Server capabilities", CheckStatus.Fail,
|
||||
reason = "No usable chat endpoint advertised",
|
||||
category = DiagnosticCategory.Api,
|
||||
)
|
||||
}
|
||||
|
||||
// 4) Chat transport readiness.
|
||||
val chatErr = recentError(DiagnosticCategory.Session) ?: recentError(DiagnosticCategory.Api)
|
||||
checks += if (chatReady) {
|
||||
StatusCheck(
|
||||
"Chat transport", CheckStatus.Pass,
|
||||
reason = "Ready · ${capabilities.preferredChatEndpoint()}",
|
||||
category = DiagnosticCategory.Session,
|
||||
)
|
||||
} else {
|
||||
val degraded = apiHealth == ConnectionViewModel.HealthStatus.Reachable
|
||||
StatusCheck(
|
||||
"Chat transport",
|
||||
if (degraded) CheckStatus.Warn else CheckStatus.Fail,
|
||||
reason = chatErr?.message() ?: "Not ready — no usable streaming endpoint",
|
||||
category = DiagnosticCategory.Session,
|
||||
timestampMs = chatErr?.timestampMs,
|
||||
)
|
||||
}
|
||||
|
||||
// 5) Relay / pairing auth.
|
||||
val authErr = recentError(DiagnosticCategory.Auth)
|
||||
checks += when (authState) {
|
||||
is AuthState.Paired ->
|
||||
StatusCheck(
|
||||
"Pairing / auth", CheckStatus.Pass,
|
||||
reason = "Relay session active",
|
||||
category = DiagnosticCategory.Auth,
|
||||
)
|
||||
is AuthState.Pairing ->
|
||||
StatusCheck(
|
||||
"Pairing / auth", CheckStatus.Warn,
|
||||
reason = "Pairing in progress…",
|
||||
category = DiagnosticCategory.Auth,
|
||||
)
|
||||
is AuthState.Failed ->
|
||||
StatusCheck(
|
||||
"Pairing / auth", CheckStatus.Fail,
|
||||
reason = authState.reason,
|
||||
category = DiagnosticCategory.Auth,
|
||||
timestampMs = authErr?.timestampMs,
|
||||
)
|
||||
is AuthState.Unpaired ->
|
||||
StatusCheck(
|
||||
"Pairing / auth", CheckStatus.Unknown,
|
||||
reason = "Not paired — vanilla Hermes path doesn't require pairing",
|
||||
category = DiagnosticCategory.Auth,
|
||||
)
|
||||
}
|
||||
|
||||
// 6) Relay server (optional — Unknown when not paired/configured).
|
||||
val relayErr = recentError(DiagnosticCategory.Relay)
|
||||
checks += when {
|
||||
!relayConfigured ->
|
||||
StatusCheck(
|
||||
"Relay server", CheckStatus.Unknown,
|
||||
reason = "Not paired — relay features are optional",
|
||||
category = DiagnosticCategory.Relay,
|
||||
)
|
||||
relayReady ->
|
||||
StatusCheck(
|
||||
"Relay server", CheckStatus.Pass,
|
||||
reason = "Connected",
|
||||
category = DiagnosticCategory.Relay,
|
||||
)
|
||||
relayHealth == ConnectionViewModel.HealthStatus.Reachable ->
|
||||
StatusCheck(
|
||||
"Relay server", CheckStatus.Warn,
|
||||
reason = relayErr?.message() ?: "Reachable but session not ready",
|
||||
category = DiagnosticCategory.Relay,
|
||||
timestampMs = relayErr?.timestampMs,
|
||||
)
|
||||
else ->
|
||||
StatusCheck(
|
||||
"Relay server", CheckStatus.Fail,
|
||||
reason = relayErr?.message() ?: "Configured but not reachable",
|
||||
category = DiagnosticCategory.Relay,
|
||||
timestampMs = relayErr?.timestampMs,
|
||||
)
|
||||
}
|
||||
|
||||
// 7) Voice readiness.
|
||||
val voiceErr = recentError(DiagnosticCategory.Voice)
|
||||
checks += if (voiceReady) {
|
||||
StatusCheck(
|
||||
"Voice", CheckStatus.Pass,
|
||||
reason = if (relayVoiceReady) "Relay voice ready" else "Standard voice ready",
|
||||
category = DiagnosticCategory.Voice,
|
||||
)
|
||||
} else {
|
||||
StatusCheck(
|
||||
"Voice",
|
||||
if (voiceErr != null) CheckStatus.Fail else CheckStatus.Unknown,
|
||||
reason = voiceErr?.message() ?: "Not configured or unavailable",
|
||||
category = DiagnosticCategory.Voice,
|
||||
timestampMs = voiceErr?.timestampMs,
|
||||
)
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
@@ -138,7 +138,7 @@ fun PermissionsStatusScreen(
|
||||
PermissionsIntroCard()
|
||||
|
||||
PermissionSection(
|
||||
title = "Vanilla Hermes",
|
||||
title = "Hermes",
|
||||
subtitle = "Chat and Manage use your configured Hermes API/dashboard connection.",
|
||||
) {
|
||||
PermissionStatusRow(
|
||||
|
||||
@@ -51,7 +51,6 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -60,7 +59,6 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -87,7 +85,6 @@ import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.ui.components.AgentAvatarFace
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.LocalAgentIconPath
|
||||
import com.hermesandroid.relay.ui.components.DiagnosticsLogPanel
|
||||
import com.hermesandroid.relay.ui.components.ProfileInspectorCard
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.gradientBorder
|
||||
@@ -143,6 +140,7 @@ fun SettingsScreen(
|
||||
onNavigateToMediaSettings: () -> Unit,
|
||||
onNavigateToAppearanceSettings: () -> Unit,
|
||||
onNavigateToAnalytics: () -> Unit,
|
||||
onNavigateToDiagnostics: () -> Unit,
|
||||
onNavigateToVoiceSettings: () -> Unit,
|
||||
onNavigateToNotificationCompanion: () -> Unit,
|
||||
onNavigateToPermissions: () -> Unit,
|
||||
@@ -271,13 +269,11 @@ fun SettingsScreen(
|
||||
// the sheet renders inline over Settings so closing drops the user
|
||||
// back where they started.
|
||||
var showAgentSheet by remember { mutableStateOf(false) }
|
||||
var showDiagnosticsSheet by remember { mutableStateOf(false) }
|
||||
var showProfileLockDialog by remember { mutableStateOf(false) }
|
||||
// What's New / Changelog — opens the full release history as a
|
||||
// self-contained full-screen Dialog (no nav route). Always available, not
|
||||
// gated on the post-update "seen" state that drives the auto dialog.
|
||||
var showChangelog by remember { mutableStateOf(false) }
|
||||
val diagnosticsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
// Profile lock state — this card/dialog is the ONE surface that always
|
||||
// lists every profile, so it does NOT gate on isProfileLocked.
|
||||
@@ -521,8 +517,8 @@ fun SettingsScreen(
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Info,
|
||||
title = "Diagnostics",
|
||||
subtitle = "Recent API, relay, session, and voice activity",
|
||||
onClick = { showDiagnosticsSheet = true },
|
||||
subtitle = "Status checks, plus recent API, relay, session, and voice activity",
|
||||
onClick = onNavigateToDiagnostics,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
@@ -571,36 +567,6 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (showDiagnosticsSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showDiagnosticsSheet = false },
|
||||
sheetState = diagnosticsSheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.navigationBarsPadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Diagnostics",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Text(
|
||||
text = "Recent app-level connection and voice events. Secrets and raw payloads are hidden.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
DiagnosticsLogPanel(
|
||||
limit = 80,
|
||||
showCategory = true,
|
||||
showClear = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showProfileLockDialog) {
|
||||
ProfileLockDialog(
|
||||
profiles = agentProfiles,
|
||||
@@ -614,7 +580,8 @@ fun SettingsScreen(
|
||||
|
||||
// Full-screen changelog. Hosted as a self-contained Dialog (no nav route)
|
||||
// so it stacks over Settings and dismisses back here — mirroring the
|
||||
// showAgentSheet / showDiagnosticsSheet inline-surface pattern above.
|
||||
// showAgentSheet inline-surface pattern above. (Diagnostics moved to its
|
||||
// own nav route — see Screen.Diagnostics.)
|
||||
if (showChangelog) {
|
||||
Dialog(
|
||||
onDismissRequest = { showChangelog = false },
|
||||
|
||||
@@ -562,22 +562,22 @@ private fun VoiceForThisProfileCard(
|
||||
val relayStatus = if (relayVoiceReady) "Ready" else "Relay not configured"
|
||||
val autoStatus = when {
|
||||
relayVoiceReady -> "Ready — using Relay"
|
||||
standardOk -> "Ready — using standard Hermes"
|
||||
standardOk -> "Ready — using Hermes"
|
||||
else -> "No route available yet"
|
||||
}
|
||||
listOf(
|
||||
RouteOption(
|
||||
route = VoiceAudioRoute.Auto,
|
||||
label = "Auto",
|
||||
detail = "Relay when paired; otherwise the standard Hermes dashboard. Recommended.",
|
||||
detail = "Relay when paired; otherwise the Hermes dashboard. Recommended.",
|
||||
status = autoStatus,
|
||||
statusOk = relayVoiceReady || standardOk,
|
||||
),
|
||||
RouteOption(
|
||||
route = VoiceAudioRoute.Standard,
|
||||
label = "Vanilla Hermes",
|
||||
label = "Hermes",
|
||||
detail = "The dashboard audio path Hermes Desktop uses — works on a " +
|
||||
"vanilla Hermes install, no Relay plugin required.",
|
||||
"Hermes install, no Relay plugin required.",
|
||||
status = standardStatus,
|
||||
statusOk = standardOk,
|
||||
),
|
||||
@@ -2104,7 +2104,7 @@ private fun TestCurrentEngineCard(
|
||||
).joinToString(" / ").ifBlank { "loading..." },
|
||||
)
|
||||
} else {
|
||||
ProviderRow(label = "Route", value = "standard Hermes")
|
||||
ProviderRow(label = "Route", value = "Hermes")
|
||||
ProviderRow(label = "Voice", value = "server-configured TTS")
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
@@ -1051,6 +1051,17 @@ class ChatViewModel : ViewModel() {
|
||||
profileSessionLister = lister
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a session scoped to the active profile on gateway connections
|
||||
* (dashboard `DELETE /api/sessions/{id}?profile=`). The write twin of
|
||||
* [profileSessionLister]: a non-default profile's row lives in that profile's
|
||||
* own DB, so the unscoped api_server delete leaves it behind and the next
|
||||
* profile-scoped list resurrects it. Returns `true` on success. Wired from
|
||||
* RelayApp to
|
||||
* [com.hermesandroid.relay.viewmodel.ConnectionViewModel.deleteProfileScopedSession].
|
||||
*/
|
||||
var profileSessionDeleter: (suspend (String) -> Boolean)? = null
|
||||
|
||||
/**
|
||||
* Loads a session's transcript scoped to the active profile (dashboard
|
||||
* `/api/sessions/{id}/messages?profile=`). Twin of [profileSessionLister]:
|
||||
@@ -1819,8 +1830,24 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val success = client.deleteSession(sessionId)
|
||||
if (!success && removedSession != null) {
|
||||
// On the gateway, the session lives in the ACTIVE PROFILE's own
|
||||
// state.db, so it must be deleted through the dashboard
|
||||
// `/api/sessions/{id}?profile=` surface — the same scoping
|
||||
// refreshSessions() uses for the listing. The unscoped api_server
|
||||
// delete leaves a non-default profile's row intact and the next
|
||||
// profile-scoped list resurrects it. Off the gateway (one shared
|
||||
// api_server DB, no profiles) the plain delete is correct; the
|
||||
// deleter is also null until RelayApp wires it, so fall back then.
|
||||
val success = if (streamingEndpoint == "gateway") {
|
||||
profileSessionDeleter?.invoke(sessionId) ?: client.deleteSession(sessionId)
|
||||
} else {
|
||||
client.deleteSession(sessionId)
|
||||
}
|
||||
if (success) {
|
||||
// Re-fetch so a server that still has the row can't leave it
|
||||
// resurrected in the drawer; mirrors session create's refresh.
|
||||
refreshSessions()
|
||||
} else if (removedSession != null) {
|
||||
// Restore on failure
|
||||
handler.addSession(removedSession)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.hermesandroid.relay.ui.components.avatar.PetLoader
|
||||
import com.hermesandroid.relay.ui.components.avatar.SphereAvatar
|
||||
import com.hermesandroid.relay.auth.PairedDeviceInfo
|
||||
import com.hermesandroid.relay.auth.PairedSession
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.DataManager
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
@@ -25,8 +26,10 @@ import com.hermesandroid.relay.data.MediaSettingsRepository
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.data.RelayEndpoint
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.ConnectionSecurity
|
||||
import com.hermesandroid.relay.data.ConnectionStore
|
||||
import com.hermesandroid.relay.data.ConnectionValidation
|
||||
import com.hermesandroid.relay.data.computeConnectionSecurity
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.SessionTransport
|
||||
@@ -1037,8 +1040,35 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
suspend fun loadProfileScopedMessages(sessionId: String): Result<List<MessageItem>>? =
|
||||
profileController.loadProfileScopedMessages(sessionId)
|
||||
|
||||
/**
|
||||
* Delete a session scoped to the ACTIVE PROFILE via the dashboard
|
||||
* `DELETE /api/sessions/{id}?profile=` surface — the write twin of
|
||||
* [listProfileScopedSessions]. A non-default profile's sessions live in that
|
||||
* profile's own `state.db`, so the unscoped api_server delete leaves the row
|
||||
* intact and the next profile-scoped list resurrects it. Resolves the active
|
||||
* connection + dashboard URL + profile name exactly as the lister does;
|
||||
* returns `false` when there's no dashboard surface so the caller can fall
|
||||
* back to the shared api_server delete.
|
||||
*/
|
||||
suspend fun deleteProfileScopedSession(sessionId: String): Boolean {
|
||||
val connectionId = activeConnectionId.value ?: return false
|
||||
val dashboardUrl = activeDashboardUrl() ?: return false
|
||||
val profileName = AgentDisplay.profileRequestName(profileController.selectedProfile.value?.name)
|
||||
return upstreamTransport.dashboardClientFor(connectionId, dashboardUrl)
|
||||
.deleteSession(sessionId, profileName)
|
||||
.isSuccess
|
||||
}
|
||||
|
||||
val selectedProfile: StateFlow<Profile?> get() = profileController.selectedProfile
|
||||
|
||||
/**
|
||||
* True once the active connection's persisted profile selection has settled,
|
||||
* so cold-start profile-scoped reads (e.g. the session drawer + restored
|
||||
* session context) don't race the restore and load the server-default
|
||||
* profile. See [ProfileController.selectionSettled].
|
||||
*/
|
||||
val profileSelectionSettled: StateFlow<Boolean> get() = profileController.selectionSettled
|
||||
|
||||
val profileDisplayAlias: StateFlow<String?> get() = profileController.profileDisplayAlias
|
||||
|
||||
fun setProfileDisplayAlias(alias: String?) = profileController.setProfileDisplayAlias(alias)
|
||||
@@ -1094,6 +1124,33 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
)
|
||||
val isTailscaleDetected: StateFlow<Boolean> = tailscaleDetector.isTailscaleDetected
|
||||
|
||||
/**
|
||||
* Single source of truth for the connection-security indicator (chat
|
||||
* status chip, connection header, route picker, detail sheet). Rolls up
|
||||
* the per-surface scheme of API / dashboard / relay against the active
|
||||
* route — overlay transports (Tailscale/WireGuard/proxy) count as
|
||||
* encrypted, not just TLS. Declared after [isTailscaleDetected] because it
|
||||
* reads it. See `data/ConnectionSecurity.kt`.
|
||||
*/
|
||||
val connectionSecurity: StateFlow<ConnectionSecurity> = combine(
|
||||
effectiveApiServerUrl,
|
||||
effectiveDashboardUrl,
|
||||
effectiveRelayUrl,
|
||||
relayConfigured,
|
||||
activeEndpoint,
|
||||
) { api, dashboard, relay, relayCfg, endpoint ->
|
||||
arrayOf(api, dashboard, relay, relayCfg, endpoint)
|
||||
}.combine(isTailscaleDetected) { values, tailscale ->
|
||||
computeConnectionSecurity(
|
||||
apiUrl = values[0] as String,
|
||||
dashboardUrl = values[1] as String,
|
||||
relayUrl = values[2] as String,
|
||||
relayConfigured = values[3] as Boolean,
|
||||
activeEndpoint = values[4] as EndpointCandidate?,
|
||||
isTailscaleDetected = tailscale,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, ConnectionSecurity.UNKNOWN)
|
||||
|
||||
// What's New tracking
|
||||
private val _showWhatsNew = MutableStateFlow(false)
|
||||
val showWhatsNew: StateFlow<Boolean> = _showWhatsNew.asStateFlow()
|
||||
@@ -4568,7 +4625,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
)
|
||||
if (candidate == null) {
|
||||
onResult(
|
||||
"Enter the API server URL — e.g. 100.71.8.56 or " +
|
||||
"Enter the API server URL — e.g. 100.64.0.1 or " +
|
||||
"http://host:8642 (http/https only; port defaults to 8642)",
|
||||
)
|
||||
return@launch
|
||||
@@ -5338,21 +5395,14 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
connectionManager.shutdown()
|
||||
// ViewModel.onCleared runs on the main thread and viewModelScope
|
||||
// is already being cancelled — fire-and-forget the client
|
||||
// shutdown on a plain background Thread so
|
||||
// ConnectionPool.evictAll doesn't trip
|
||||
// onCleared runs on the main thread, but every client's shutdown()
|
||||
// routes ConnectionPool.evictAll() (a synchronous TLS socket close /
|
||||
// network write) off the main thread internally via
|
||||
// shutdownOffMainThread, so these direct calls can't trip
|
||||
// NetworkOnMainThreadException on live SSL sockets.
|
||||
_apiClient.value?.let { client ->
|
||||
Thread({ runCatching { client.shutdown() } }, "HermesApiClient-shutdown").start()
|
||||
}
|
||||
profileChatApiClient?.let { client ->
|
||||
Thread(
|
||||
{ runCatching { client.shutdown() } },
|
||||
"HermesProfileApiClient-shutdown",
|
||||
).start()
|
||||
}
|
||||
connectionManager.shutdown()
|
||||
_apiClient.value?.shutdown()
|
||||
profileChatApiClient?.shutdown()
|
||||
tailscaleDetector.shutdown()
|
||||
// Release the cached VirtualDisplay + ImageReader + HandlerThread
|
||||
// built by ScreenCapture on the first /screenshot call. Without
|
||||
|
||||
@@ -106,6 +106,43 @@ class ProfileController(
|
||||
private val _pendingSelectedProfileConnectionId = MutableStateFlow<String?>(null)
|
||||
private val _pendingSelectedProfileName = MutableStateFlow<String?>(null)
|
||||
|
||||
/**
|
||||
* True once the active connection's persisted profile selection has SETTLED
|
||||
* — i.e. profile-scoped reads (session drawer, transcript restore, voice
|
||||
* prefs) can run without racing the cold-start restore and wrongly loading
|
||||
* the SERVER-DEFAULT profile. Settled when any of these hold:
|
||||
* - there's no active connection yet (nothing profile-scoped to gate), or
|
||||
* - the selection has resolved into [selectedProfile], or
|
||||
* - no NON-default profile is pending for the active connection (server
|
||||
* default / nothing to wait for), or
|
||||
* - the agent-profile list has arrived, so resolution has been ATTEMPTED —
|
||||
* a genuinely-missing profile then falls back to server default rather
|
||||
* than gating forever.
|
||||
*
|
||||
* False only in the cold-start window where a non-default profile name is
|
||||
* persisted but the profile list hasn't landed yet to resolve it — exactly
|
||||
* when an unscoped read would load the server-default profile by mistake.
|
||||
*/
|
||||
val selectionSettled: StateFlow<Boolean> = combine(
|
||||
activeConnectionId,
|
||||
selectedProfile,
|
||||
_pendingSelectedProfileConnectionId,
|
||||
_pendingSelectedProfileName,
|
||||
agentProfiles,
|
||||
) { connId, selected, pendingConnId, pendingName, profiles ->
|
||||
when {
|
||||
connId == null -> true
|
||||
selected != null -> true
|
||||
// Pending state still points at a previous connection mid-switch —
|
||||
// hold until this connection's restore re-stamps the pending name.
|
||||
pendingConnId != connId -> false
|
||||
pendingName == null || AgentDisplay.isServerDefaultAlias(pendingName) -> true
|
||||
// Non-default name pending: settled once the profile list is present
|
||||
// (resolution attempted), even if the name turns out to be gone.
|
||||
else -> profiles.isNotEmpty()
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, false)
|
||||
|
||||
/**
|
||||
* DataStore-backed persistence for the selected profile keyed by
|
||||
* connection id. Public so the ViewModel's connection-lifecycle
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Locks the connection-security rollup (the single source of truth behind the
|
||||
* in-app indicator). The headline correctness property: a plaintext route over
|
||||
* an overlay network (Tailscale/WireGuard) is **encrypted**, not "insecure".
|
||||
*/
|
||||
class ConnectionSecurityTest {
|
||||
|
||||
private fun endpoint(role: String, security: String? = null) = EndpointCandidate(
|
||||
role = role,
|
||||
api = ApiEndpoint(host = "h", port = 8642),
|
||||
relay = RelayEndpoint(url = "ws://h:8767"),
|
||||
security = security,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun allTlsSurfaces_rollUpToTls() {
|
||||
val result = computeConnectionSecurity(
|
||||
apiUrl = "https://h:8642",
|
||||
dashboardUrl = "https://h:9119",
|
||||
relayUrl = "wss://h:8767",
|
||||
relayConfigured = true,
|
||||
activeEndpoint = endpoint("public"),
|
||||
isTailscaleDetected = false,
|
||||
)
|
||||
assertEquals(ConnectionSecurityLevel.Tls, result.level)
|
||||
assertEquals("TLS", result.mechanism)
|
||||
assertEquals(3, result.surfaces.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plaintextOverTailscale_isEncryptedOverlay_notPlain() {
|
||||
val result = computeConnectionSecurity(
|
||||
apiUrl = "http://100.71.0.1:8642",
|
||||
dashboardUrl = "http://100.71.0.1:9119",
|
||||
relayUrl = "ws://100.71.0.1:8767",
|
||||
relayConfigured = true,
|
||||
activeEndpoint = endpoint("tailscale"),
|
||||
isTailscaleDetected = false,
|
||||
)
|
||||
assertEquals(ConnectionSecurityLevel.Overlay, result.level)
|
||||
assertEquals("Tailscale", result.mechanism)
|
||||
// The whole point: overlay counts as encrypted.
|
||||
assertEquals(true, result.isEncrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun someTlsSomePlain_isMixed() {
|
||||
val result = computeConnectionSecurity(
|
||||
apiUrl = "https://h:8642",
|
||||
dashboardUrl = "https://h:9119",
|
||||
relayUrl = "ws://h:8767",
|
||||
relayConfigured = true,
|
||||
activeEndpoint = endpoint("lan"),
|
||||
isTailscaleDetected = false,
|
||||
)
|
||||
assertEquals(ConnectionSecurityLevel.Mixed, result.level)
|
||||
assertEquals(false, result.isEncrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allPlainLan_isPlain_withRoleMechanism() {
|
||||
val result = computeConnectionSecurity(
|
||||
apiUrl = "http://192.168.1.10:8642",
|
||||
dashboardUrl = "http://192.168.1.10:9119",
|
||||
relayUrl = "ws://192.168.1.10:8767",
|
||||
relayConfigured = true,
|
||||
activeEndpoint = endpoint("lan"),
|
||||
isTailscaleDetected = false,
|
||||
)
|
||||
assertEquals(ConnectionSecurityLevel.Plain, result.level)
|
||||
assertEquals("LAN", result.mechanism)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayNotConfigured_excludesRelaySurface() {
|
||||
val result = computeConnectionSecurity(
|
||||
apiUrl = "https://h:8642",
|
||||
dashboardUrl = "https://h:9119",
|
||||
relayUrl = "ws://h:8767", // plain, but relay not configured → ignored
|
||||
relayConfigured = false,
|
||||
activeEndpoint = endpoint("public"),
|
||||
isTailscaleDetected = false,
|
||||
)
|
||||
assertEquals(ConnectionSecurityLevel.Tls, result.level)
|
||||
assertEquals(2, result.surfaces.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noSurfaces_isUnknown() {
|
||||
val result = computeConnectionSecurity(
|
||||
apiUrl = "",
|
||||
dashboardUrl = "",
|
||||
relayUrl = "",
|
||||
relayConfigured = false,
|
||||
activeEndpoint = null,
|
||||
isTailscaleDetected = false,
|
||||
)
|
||||
assertEquals(ConnectionSecurityLevel.Unknown, result.level)
|
||||
assertEquals(ConnectionSecurity.UNKNOWN, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deviceTailscaleDetected_withSecurityHint_classifiesOverlay() {
|
||||
val result = computeConnectionSecurity(
|
||||
apiUrl = "http://host:8642",
|
||||
dashboardUrl = "http://host:9119",
|
||||
relayUrl = "ws://host:8767",
|
||||
relayConfigured = false,
|
||||
activeEndpoint = endpoint(role = "custom", security = "tailscale-magicdns"),
|
||||
isTailscaleDetected = true,
|
||||
)
|
||||
assertEquals(ConnectionSecurityLevel.Overlay, result.level)
|
||||
assertEquals("Tailscale", result.mechanism)
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -18,8 +18,8 @@ class ConnectionUrlInputNormalizationTest {
|
||||
@Test
|
||||
fun bareIp_getsSchemeAndDefaultPort() {
|
||||
assertEquals(
|
||||
"http://100.71.8.56:8642",
|
||||
Connection.normalizeApiUrlInput("100.71.8.56"),
|
||||
"http://100.64.0.1:8642",
|
||||
Connection.normalizeApiUrlInput("100.64.0.1"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,8 +60,8 @@ class ConnectionUrlInputNormalizationTest {
|
||||
@Test
|
||||
fun whitespaceAndTrailingSlash_areTrimmed() {
|
||||
assertEquals(
|
||||
"http://100.71.8.56:8642",
|
||||
Connection.normalizeApiUrlInput(" 100.71.8.56/ "),
|
||||
"http://100.64.0.1:8642",
|
||||
Connection.normalizeApiUrlInput(" 100.64.0.1/ "),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class ConnectionUrlInputNormalizationTest {
|
||||
// End-to-end: the exact user journey from the bug report — typing a
|
||||
// bare Tailscale IP must yield a plain-HTTP (tls=false) candidate on
|
||||
// port 8642 with the tailscale role inferred.
|
||||
val normalized = Connection.normalizeApiUrlInput("100.71.8.56")
|
||||
val normalized = Connection.normalizeApiUrlInput("100.64.0.1")
|
||||
val candidate = Connection.endpointCandidateFromApiUrl(
|
||||
role = "",
|
||||
priority = 1,
|
||||
@@ -103,7 +103,7 @@ class ConnectionUrlInputNormalizationTest {
|
||||
relayUrl = "",
|
||||
)
|
||||
assertEquals("tailscale", candidate?.role)
|
||||
assertEquals("100.71.8.56", candidate?.api?.host)
|
||||
assertEquals("100.64.0.1", candidate?.api?.host)
|
||||
assertEquals(8642, candidate?.api?.port)
|
||||
assertEquals(false, candidate?.api?.tls)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.os.Looper
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* Guards the fix for the `NetworkOnMainThreadException` crash (issues #70 /
|
||||
* #118 / #124): client `shutdown()` reaches `ConnectionPool.evictAll()`, which
|
||||
* closes live TLS sockets with a synchronous network write. The teardown block
|
||||
* must never execute on the main thread.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class NetworkShutdownTest {
|
||||
|
||||
@Test
|
||||
fun whenCalledOnMainThread_runsTeardownOffTheMainThread() {
|
||||
// Robolectric drives the test body on the main looper — the same place
|
||||
// a viewModelScope (Dispatchers.Main.immediate) coroutine resumes and
|
||||
// shuts a dashboard/API client down in a `finally` block.
|
||||
assertSame(Looper.myLooper(), Looper.getMainLooper())
|
||||
val mainThread = Looper.getMainLooper().thread
|
||||
|
||||
val ranOn = AtomicReference<Thread>()
|
||||
val latch = CountDownLatch(1)
|
||||
shutdownOffMainThread("test-shutdown") {
|
||||
ranOn.set(Thread.currentThread())
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
assertTrue("teardown block never ran", latch.await(5, TimeUnit.SECONDS))
|
||||
assertNotSame(
|
||||
"evictAll must not run on the main thread",
|
||||
mainThread,
|
||||
ranOn.get(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenCalledOffMainThread_runsTeardownInline() {
|
||||
val ranOn = AtomicReference<Thread>()
|
||||
val latch = CountDownLatch(1)
|
||||
val worker = Thread {
|
||||
shutdownOffMainThread("test-shutdown") { ranOn.set(Thread.currentThread()) }
|
||||
latch.countDown()
|
||||
}
|
||||
worker.start()
|
||||
|
||||
assertTrue(latch.await(5, TimeUnit.SECONDS))
|
||||
// Off the main thread the block runs inline (no extra hop), preserving
|
||||
// the blocking awaitTermination semantics for callers already off main.
|
||||
assertSame(worker, ranOn.get())
|
||||
}
|
||||
}
|
||||
@@ -10,16 +10,16 @@ class RelayUrlDeriverTest {
|
||||
@Test
|
||||
fun derivesPlainLanRelayUrlFromApiUrl() {
|
||||
assertEquals(
|
||||
"ws://172.16.24.250:8767",
|
||||
RelayUrlDeriver.deriveFromApiUrl("http://172.16.24.250:8642"),
|
||||
"ws://192.168.1.100:8767",
|
||||
RelayUrlDeriver.deriveFromApiUrl("http://192.168.1.100:8642"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun derivesTlsRelayUrlFromApiUrl() {
|
||||
assertEquals(
|
||||
"wss://docker-server.tailnet.ts.net:8767",
|
||||
RelayUrlDeriver.deriveFromApiUrl("https://docker-server.tailnet.ts.net:8642"),
|
||||
"wss://hermes-host.tailnet.ts.net:8767",
|
||||
RelayUrlDeriver.deriveFromApiUrl("https://hermes-host.tailnet.ts.net:8642"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -9,10 +9,10 @@ class ProfileApiUrlResolverTest {
|
||||
@Test
|
||||
fun resolveForConnection_rewritesLoopbackProfileHostToBaseHost() {
|
||||
assertEquals(
|
||||
"http://172.16.24.250:8647",
|
||||
"http://192.168.1.100:8647",
|
||||
ProfileApiUrlResolver.resolveForConnection(
|
||||
profileApiUrl = "http://127.0.0.1:8647",
|
||||
baseApiUrl = "http://172.16.24.250:8642",
|
||||
baseApiUrl = "http://192.168.1.100:8642",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -20,10 +20,10 @@ class ProfileApiUrlResolverTest {
|
||||
@Test
|
||||
fun resolveForConnection_rewritesZeroBindHostToBaseHost() {
|
||||
assertEquals(
|
||||
"https://docker-server.tailnet.ts.net:8646",
|
||||
"https://hermes-host.tailnet.ts.net:8646",
|
||||
ProfileApiUrlResolver.resolveForConnection(
|
||||
profileApiUrl = "http://0.0.0.0:8646/",
|
||||
baseApiUrl = "https://docker-server.tailnet.ts.net:8642/",
|
||||
baseApiUrl = "https://hermes-host.tailnet.ts.net:8642/",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class ProfileApiUrlResolverTest {
|
||||
"http://192.168.1.50:8647",
|
||||
ProfileApiUrlResolver.resolveForConnection(
|
||||
profileApiUrl = "http://192.168.1.50:8647",
|
||||
baseApiUrl = "http://172.16.24.250:8642",
|
||||
baseApiUrl = "http://192.168.1.100:8642",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -55,7 +55,7 @@ class ProfileApiUrlResolverTest {
|
||||
assertNull(
|
||||
ProfileApiUrlResolver.resolveForConnection(
|
||||
profileApiUrl = " ",
|
||||
baseApiUrl = "http://172.16.24.250:8642",
|
||||
baseApiUrl = "http://192.168.1.100:8642",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,11 @@ import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
@@ -1196,6 +1199,128 @@ class ChatHandlerTest {
|
||||
assertNull(handler.messages.value[0].voiceIntent)
|
||||
}
|
||||
|
||||
|
||||
// --- Relay typed stream.event rendering ---
|
||||
|
||||
@Test
|
||||
fun applyRelayStreamEvent_rendersAssistantDeltaToolLifecycleAndDone() {
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "assist-relay",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = 1L,
|
||||
isStreaming = true,
|
||||
)
|
||||
)
|
||||
|
||||
handler.applyRelayStreamEvent(
|
||||
"assist-relay",
|
||||
RelayStreamEventEnvelope(
|
||||
sessionId = "sess-1",
|
||||
runId = "run-1",
|
||||
seq = 1,
|
||||
event = "assistant.delta",
|
||||
payload = buildJsonObject { put("delta", "Hello") },
|
||||
),
|
||||
)
|
||||
handler.applyRelayStreamEvent(
|
||||
"assist-relay",
|
||||
RelayStreamEventEnvelope(
|
||||
sessionId = "sess-1",
|
||||
runId = "run-1",
|
||||
seq = 2,
|
||||
event = "tool.started",
|
||||
payload = buildJsonObject {
|
||||
put("tool_name", "terminal")
|
||||
put("call_id", "call-1")
|
||||
},
|
||||
),
|
||||
)
|
||||
handler.applyRelayStreamEvent(
|
||||
"assist-relay",
|
||||
RelayStreamEventEnvelope(
|
||||
sessionId = "sess-1",
|
||||
runId = "run-1",
|
||||
seq = 3,
|
||||
event = "tool.completed",
|
||||
payload = buildJsonObject {
|
||||
put("tool_name", "terminal")
|
||||
put("call_id", "call-1")
|
||||
put("result_preview", "ok")
|
||||
},
|
||||
),
|
||||
)
|
||||
handler.applyRelayStreamEvent(
|
||||
"assist-relay",
|
||||
RelayStreamEventEnvelope(
|
||||
sessionId = "sess-1",
|
||||
runId = "run-1",
|
||||
seq = 4,
|
||||
event = "done",
|
||||
payload = buildJsonObject { put("state", "final") },
|
||||
),
|
||||
)
|
||||
|
||||
val msg = handler.messages.value.single()
|
||||
assertEquals("Hello", msg.content)
|
||||
assertFalse(msg.isStreaming)
|
||||
assertEquals(1, msg.toolCalls.size)
|
||||
assertEquals("terminal", msg.toolCalls[0].name)
|
||||
assertTrue(msg.toolCalls[0].isComplete)
|
||||
assertEquals("ok", msg.toolCalls[0].result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applyRelayStreamEvent_rendersProgressArtifactAndErrorStates() {
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "assist-relay",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = 1L,
|
||||
isStreaming = true,
|
||||
)
|
||||
)
|
||||
|
||||
handler.applyRelayStreamEvent(
|
||||
"assist-relay",
|
||||
RelayStreamEventEnvelope(
|
||||
sessionId = "sess-1",
|
||||
runId = "run-1",
|
||||
seq = 1,
|
||||
event = "tool.progress",
|
||||
payload = buildJsonObject { put("delta", "Thinking...") },
|
||||
),
|
||||
)
|
||||
handler.applyRelayStreamEvent(
|
||||
"assist-relay",
|
||||
RelayStreamEventEnvelope(
|
||||
sessionId = "sess-1",
|
||||
runId = "run-1",
|
||||
seq = 2,
|
||||
event = "artifact.created",
|
||||
payload = buildJsonObject { put("url", "https://example.invalid/artifact") },
|
||||
),
|
||||
)
|
||||
handler.applyRelayStreamEvent(
|
||||
"assist-relay",
|
||||
RelayStreamEventEnvelope(
|
||||
sessionId = "sess-1",
|
||||
runId = "run-1",
|
||||
seq = 3,
|
||||
event = "error",
|
||||
payload = buildJsonObject { put("message", "boom") },
|
||||
),
|
||||
)
|
||||
|
||||
val msg = handler.messages.value.single()
|
||||
assertTrue(msg.thinkingContent.contains("Thinking..."))
|
||||
assertTrue(msg.thinkingContent.contains("Artifact:"))
|
||||
assertTrue(msg.badges.contains("Error"))
|
||||
assertEquals("boom", handler.error.value)
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
|
||||
private fun createUserMessage(id: String, content: String) = ChatMessage(
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ class DashboardManageDiskCacheTest {
|
||||
}
|
||||
|
||||
private fun sampleEntries(): Map<String, PersistedDashboardPayload> = mapOf(
|
||||
"conn-1|http://100.71.8.56:9119|/api/skills" to PersistedDashboardPayload(
|
||||
"conn-1|http://100.64.0.1:9119|/api/skills" to PersistedDashboardPayload(
|
||||
status = DashboardStatus(
|
||||
authRequired = true,
|
||||
authProviders = listOf("password"),
|
||||
|
||||
+7
-7
@@ -143,11 +143,11 @@ In the tray app, open **Pair** and paste the `hermes-relay://pair?...` URL into
|
||||
hermes-relay pair --pair-qr 'hermes-relay://pair?payload=...' --grant-tools
|
||||
# ✓ Paired. Token stored in ~/.hermes/remote-sessions.json
|
||||
# Server: 0.6.0
|
||||
# Relay: ws://172.16.24.250:8767
|
||||
# Relay: ws://192.168.1.100:8767
|
||||
```
|
||||
|
||||
Manual URL + six-character code pairing still works with
|
||||
`hermes-relay pair --remote ws://172.16.24.250:8767`, but the invite URL is
|
||||
`hermes-relay pair --remote ws://192.168.1.100:8767`, but the invite URL is
|
||||
the preferred path because it carries endpoint candidates and the correct
|
||||
relay one-shot code.
|
||||
|
||||
@@ -195,7 +195,7 @@ Herm uses `bun add -g herm-tui` when Bun is available and falls back to `npm ins
|
||||
If you plan to run `daemon` (headless tool serving), tack `--grant-tools` onto `pair` to capture the per-URL desktop-tool consent in the same step. That removes the historical `pair` → `shell` (consent prompt) → `daemon` dance:
|
||||
|
||||
```sh
|
||||
hermes-relay pair --remote ws://172.16.24.250:8767 --grant-tools
|
||||
hermes-relay pair --remote ws://192.168.1.100:8767 --grant-tools
|
||||
# ...prompts for code, then prompts for tool consent, stamps it on the stored session.
|
||||
|
||||
hermes-relay daemon
|
||||
@@ -206,7 +206,7 @@ For non-interactive provisioning (CI, install scripts, automated boxes) use `--a
|
||||
|
||||
```sh
|
||||
HERMES_RELAY_CODE=F3W7EY hermes-relay pair \
|
||||
--remote ws://172.16.24.250:8767 --auto-grant-tools --non-interactive
|
||||
--remote ws://192.168.1.100:8767 --auto-grant-tools --non-interactive
|
||||
```
|
||||
|
||||
The two flags are deliberately separate so consent is never implicit — `--grant-tools` means "ask me", `--auto-grant-tools` means "I've already decided". Plain `pair` (no flag) leaves consent untouched, matching the original behavior.
|
||||
@@ -326,7 +326,7 @@ hermes-relay
|
||||
```
|
||||
|
||||
```
|
||||
Connecting to ws://172.16.24.250:8767...
|
||||
Connecting to ws://192.168.1.100:8767...
|
||||
Connected (server 0.6.0).
|
||||
Session 4a3c1f2e… on claude-opus-4-7
|
||||
|
||||
@@ -374,7 +374,7 @@ hermes-relay tools
|
||||
```
|
||||
|
||||
```
|
||||
Server: ws://172.16.24.250:8767
|
||||
Server: ws://192.168.1.100:8767
|
||||
Version: 0.6.0
|
||||
Toolsets: 18 (12 enabled)
|
||||
|
||||
@@ -433,7 +433,7 @@ $ hermes-relay daemon status
|
||||
hermes-relay daemon
|
||||
state: ● connected
|
||||
pid: 48213
|
||||
relay: ws://172.16.24.250:8767
|
||||
relay: ws://192.168.1.100:8767
|
||||
uptime: 3h 12m
|
||||
updated: 4s ago
|
||||
server: 1.2.0
|
||||
|
||||
+2
-2
@@ -222,7 +222,7 @@ Flags:
|
||||
|
||||
Examples:
|
||||
# First time: pair with the relay (one-time code from \`hermes-pair\` on the server)
|
||||
hermes-relay pair --remote ws://172.16.24.250:8767
|
||||
hermes-relay pair --remote ws://192.168.1.100:8767
|
||||
# ...prompts for code, stores a token in ~/.hermes/remote-sessions.json
|
||||
|
||||
# REPL — reuses the tray-selected active relay or stored token
|
||||
@@ -248,7 +248,7 @@ Examples:
|
||||
hermes-relay plugins launch herm
|
||||
|
||||
# Two-command bring-up: pair with consent, then run headless. No \`shell\` round-trip.
|
||||
hermes-relay pair --remote ws://172.16.24.250:8767 --grant-tools
|
||||
hermes-relay pair --remote ws://192.168.1.100:8767 --grant-tools
|
||||
hermes-relay daemon
|
||||
|
||||
Config files:
|
||||
|
||||
@@ -482,6 +482,11 @@ export class RelayTransport extends EventEmitter implements Transport {
|
||||
payload.ttl_seconds = this.cfg.ttlSeconds
|
||||
}
|
||||
|
||||
payload.supports = {
|
||||
typed_stream_events: true,
|
||||
event_schema_version: 1
|
||||
}
|
||||
|
||||
this.sendEnvelope('system', 'auth', payload)
|
||||
}
|
||||
|
||||
|
||||
+9
-3
@@ -438,7 +438,7 @@ The bare-path fetch is therefore safe as long as operators treat the allowed-roo
|
||||
|
||||
**Decision:** Replace the minimal pairing model (one-shot code → fixed-30-day session token → no channel separation → `EncryptedSharedPreferences` storage) with a layered architecture built around four ideas:
|
||||
|
||||
1. **User chooses session TTL at pair time** — 1 day / 7 days / 30 days / 90 days / 1 year / **never expire**. The Android TTL picker dialog always opens on QR scan so the user explicitly confirms. Defaults depend on transport: wss or Tailscale → 30d; plain ws → 7d. Never-expire is ALWAYS selectable with an inline warning — per operator direction, trust the user's intent rather than gating on secure-transport detection.
|
||||
1. **User chooses session TTL at pair time** — 1 day / 7 days / 30 days / 90 days / 1 year / **never expire**. The Android TTL picker dialog always opens on QR scan so the user explicitly confirms. Defaults depend on transport: wss or Tailscale → 30d; plain ws → 7d. (Both `wss` and Tailscale are treated as *secure transports* here — but for different reasons: `wss` is TLS, while Tailscale's security comes from WireGuard end-to-end encryption, not TLS. See [`user-docs/architecture/connection-security.md`](../user-docs/architecture/connection-security.md).) Never-expire is ALWAYS selectable with an inline warning — per operator direction, trust the user's intent rather than gating on secure-transport detection.
|
||||
2. **Per-channel grants** — one session token, separate expiries for `chat` / `terminal` / `bridge`; later releases added `tui` and split voice grants (`voice:config`, `voice:stt`, `voice:tts`). Blast-radius-heavy channels can have shorter caps, and all grants are clamped to the session lifetime. Chat runs through the hermes-agent API server rather than the relay, so the chat grant is informational only (used by the phone UI to show scope).
|
||||
3. **Hardware-backed token storage with graceful fallback** — `KeystoreTokenStore` requests StrongBox-backed keys via `setRequestStrongBoxBacked(true)` on Android 9+ devices that advertise `FEATURE_STRONGBOX_KEYSTORE`. Falls back to the existing `LegacyEncryptedPrefsTokenStore` (TEE-backed `EncryptedSharedPreferences`) on older devices or when the Keystore path throws. Migration is one-shot and lossless — users never lose a session to an app upgrade.
|
||||
4. **TOFU cert pinning with explicit reset on re-pair** — `CertPinStore` records SHA-256 SPKI fingerprints per `host:port` on the first successful wss connect. Subsequent connects build an OkHttp `CertificatePinner` from the stored pin. A user-initiated QR re-pair (`applyServerIssuedCodeAndReset(code, relayUrl)`) wipes the pin for the target host — re-pair is explicit consent to potentially-new cert material. Plaintext ws:// short-circuits pinning entirely.
|
||||
@@ -1077,8 +1077,14 @@ priority-0 candidate from the top-level fields when `endpoints` is absent.
|
||||
phone falls through to the next candidate in priority order.
|
||||
- **TTL defaults by role** (informational — operator can override at
|
||||
pair time): `lan` → 7 days, `tailscale` → 30 days, `public` → 30 days,
|
||||
unknown role → 7 days (conservative). Plaintext-`ws://` consent still
|
||||
gates any candidate with `transport_hint = "ws"`.
|
||||
unknown role → 7 days (conservative). The longer `tailscale` default
|
||||
reflects that the tailnet is a *secure transport* (WireGuard end-to-end
|
||||
encryption + device identity) — not that the link is TLS. A
|
||||
`tailscale` candidate can carry a plain `transport_hint = "ws"` and
|
||||
still be encrypted; that's WireGuard, not TLS. See
|
||||
[`user-docs/architecture/connection-security.md`](../user-docs/architecture/connection-security.md).
|
||||
Plaintext-`ws://` consent still gates any candidate with
|
||||
`transport_hint = "ws"`.
|
||||
|
||||
**Canonicalization for the HMAC signature:** `canonicalize()` in
|
||||
`plugin/relay/qr_sign.py` uses `json.dumps(sort_keys=True,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":1,"event":"session.created","ts":"2026-06-05T00:00:00Z","payload":{"title":"Typed stream fixture"}}
|
||||
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":2,"event":"run.started","ts":"2026-06-05T00:00:01Z","payload":{"user_message":{"id":"user_1","role":"user","content":"Run a command"}}}
|
||||
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":3,"event":"message.started","ts":"2026-06-05T00:00:02Z","payload":{"message":{"id":"msg_1","role":"assistant"}}}
|
||||
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":4,"event":"tool.progress","ts":"2026-06-05T00:00:03Z","payload":{"delta":"Preparing terminal command"}}
|
||||
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":5,"event":"tool.started","ts":"2026-06-05T00:00:04Z","payload":{"tool_name":"terminal","call_id":"call_1","preview":"echo ok","args":{"cmd":"echo ok"}}}
|
||||
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":6,"event":"tool.completed","ts":"2026-06-05T00:00:05Z","payload":{"tool_name":"terminal","call_id":"call_1","result_preview":"ok","success":true}}
|
||||
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":7,"event":"artifact.created","ts":"2026-06-05T00:00:06Z","payload":{"title":"terminal-log.txt","url":"https://preview.example.invalid/artifacts/terminal-log.txt"}}
|
||||
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":8,"event":"assistant.delta","ts":"2026-06-05T00:00:07Z","payload":{"message_id":"msg_1","delta":"Command completed successfully."}}
|
||||
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":9,"event":"assistant.completed","ts":"2026-06-05T00:00:08Z","payload":{"message_id":"msg_1","completed":true,"partial":false,"interrupted":false}}
|
||||
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":10,"event":"run.completed","ts":"2026-06-05T00:00:09Z","payload":{"completed":true,"partial":false,"interrupted":false}}
|
||||
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":11,"event":"done","ts":"2026-06-05T00:00:10Z","payload":{"state":"final"}}
|
||||
@@ -1014,7 +1014,7 @@
|
||||
<div class="sphere"></div>
|
||||
<div class="thread">
|
||||
<div class="bubble user">Can you check the relay sessions and keep the terminal nearby?</div>
|
||||
<div class="bubble tool">terminal.attach · session: docker-server</div>
|
||||
<div class="bubble tool">terminal.attach · session: hermes-host</div>
|
||||
<div class="bubble">I found 3 active grants. Terminal is attached and ready.</div>
|
||||
</div>
|
||||
<div class="quick-rail">
|
||||
@@ -1278,7 +1278,7 @@
|
||||
<span>rail</span>
|
||||
</div>
|
||||
<div class="tile-list">
|
||||
<div class="tile primary"><div class="mini-icon">⌁</div><div><h4>Terminal</h4><p>docker-server</p></div><div></div></div>
|
||||
<div class="tile primary"><div class="mini-icon">⌁</div><div><h4>Terminal</h4><p>hermes-host</p></div><div></div></div>
|
||||
<div class="tile"><div class="mini-icon">▤</div><div><h4>Notifications</h4><p>2 apps shared</p></div><div></div></div>
|
||||
<div class="tile"><div class="mini-icon">☤</div><div><h4>Profile</h4><p>Victor memory</p></div><div></div></div>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
> - [`ROADMAP.md`](../../ROADMAP.md) — where this pass sits in the broader arc
|
||||
> - [`docs/plans/2026-04-13-bridge-feature-expansion.md`](./2026-04-13-bridge-feature-expansion.md) — precedent for plan-file format
|
||||
> - [`DEVLOG.md`](../../DEVLOG.md) — append session entry on completion
|
||||
> - Upstream reference: `~/.hermes/hermes-agent/tools/tts_tool.py` on hermes-host (`bailey@172.16.24.250`)
|
||||
> - Upstream reference: `~/.hermes/hermes-agent/tools/tts_tool.py` on hermes-host (`you@hermes-host`)
|
||||
|
||||
## How to use this file
|
||||
|
||||
@@ -83,11 +83,11 @@ cd ../hermes-android-voice
|
||||
|
||||
**Implementation notes.**
|
||||
- `eleven_flash_v2_5` costs fewer characters per dollar than `multilingual_v2` — net win on billing too.
|
||||
- Voice ID stays at `XZEfcFyBnzsNJrdvkWdI` (Bailey's current custom voice).
|
||||
- Voice ID stays at `<your-voice-id>` (Bailey's current custom voice).
|
||||
- Rollback: revert the config line, restart gateway. Zero risk.
|
||||
|
||||
**Agent brief.**
|
||||
> This is an operator-executed step, not an agent task. The orchestrator should SSH to `bailey@172.16.24.250` (key auth), edit `~/.hermes/config.yaml`, restart `hermes-gateway.service`, and verify with a single voice request. Document the before/after impression in the session commit message of Wave 1.
|
||||
> This is an operator-executed step, not an agent task. The orchestrator should SSH to `you@hermes-host` (key auth), edit `~/.hermes/config.yaml`, restart `hermes-gateway.service`, and verify with a single voice request. Document the before/after impression in the session commit message of Wave 1.
|
||||
|
||||
**Dependencies.** None.
|
||||
|
||||
@@ -210,7 +210,7 @@ cd ../hermes-android-voice
|
||||
- Keep the PR scope narrow — one function + one config key + one docs page. Don't bundle other voice improvements.
|
||||
|
||||
**Agent brief.**
|
||||
> SSH to `bailey@172.16.24.250`. Work in `~/.hermes/hermes-agent/` on the `Codename-11/hermes-agent` fork. Create branch `feat/elevenlabs-voice-settings`. Patch `tools/tts_tool.py::_generate_elevenlabs` to accept `voice_settings` from the elevenlabs config block and pass through a `VoiceSettings(...)` object (defaults: stability=0.65, similarity_boost=0.8, style=0.0, use_speaker_boost=True). Add a graceful degradation for older elevenlabs SDKs. Update the upstream `docs/configuration/tts.md` reference. Run the project's test suite. Push the branch, open a PR against `NousResearch/hermes-agent` — cross-reference PR #8556 for style. Then apply the patch locally on the running `axiom` branch and restart `hermes-gateway` so V2/V3/V4 can benefit during testing. Report back the PR URL and the locally-applied confirmation.
|
||||
> SSH to `you@hermes-host`. Work in `~/.hermes/hermes-agent/` on the `Codename-11/hermes-agent` fork. Create branch `feat/elevenlabs-voice-settings`. Patch `tools/tts_tool.py::_generate_elevenlabs` to accept `voice_settings` from the elevenlabs config block and pass through a `VoiceSettings(...)` object (defaults: stability=0.65, similarity_boost=0.8, style=0.0, use_speaker_boost=True). Add a graceful degradation for older elevenlabs SDKs. Update the upstream `docs/configuration/tts.md` reference. Run the project's test suite. Push the branch, open a PR against `NousResearch/hermes-agent` — cross-reference PR #8556 for style. Then apply the patch locally on the running `axiom` branch and restart `hermes-gateway` so V2/V3/V4 can benefit during testing. Report back the PR URL and the locally-applied confirmation.
|
||||
|
||||
**Dependencies.** Benefits from Cfg1 being done first (both ship settings for ElevenLabs — makes config review cleaner).
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
## Goal
|
||||
|
||||
`hermes --remote wss://docker-server.ts.net:8767` on Windows (or any machine with Node ≥ 20) opens the Hermes TUI with full feature parity — approvals, tool cards, image paste, voice, session resume — against a remote `hermes-agent` brain. No SSH, no X11 forwarding, no `mosh`. WSS transport, bearer auth, TOFU cert pinning, reconnect-safe.
|
||||
`hermes --remote wss://hermes-host.tailnet.ts.net:8767` on Windows (or any machine with Node ≥ 20) opens the Hermes TUI with full feature parity — approvals, tool cards, image paste, voice, session resume — against a remote `hermes-agent` brain. No SSH, no X11 forwarding, no `mosh`. WSS transport, bearer auth, TOFU cert pinning, reconnect-safe.
|
||||
|
||||
This is the **v0.1 / Option C (hybrid)** shipping target from the high-level design. All tools still execute **server-side**. Per-tool client-side routing (Option B) is explicitly v2.
|
||||
|
||||
@@ -95,7 +95,7 @@ Confirm exact invocation by reading `hermes_cli/main.py:1034` area (agent will d
|
||||
- `~/.hermes/remote-sessions.json` storage (mirror Android `SessionTokenStore` semantics — fail closed, atomic write).
|
||||
- Desktop cert-pin store (JSON file, SHA-256 SPKI per `host:port`, first-seen TOFU).
|
||||
- Reconnect/backoff logic in `RelayTransport`.
|
||||
- **End-to-end test:** from this Docker-Server → start relay → paste code → from Windows → `hermes --remote wss://docker-server.ts.net:8767` → full session works (prompt, tool use, image paste, approval modal).
|
||||
- **End-to-end test:** from this hermes-host → start relay → paste code → from Windows → `hermes --remote wss://hermes-host.tailnet.ts.net:8767` → full session works (prompt, tool use, image paste, approval modal).
|
||||
|
||||
### Phase 4 — Polish + docs *(post-smoke)*
|
||||
- Update `~/.hermes/hermes-relay/README.md` with desktop install section.
|
||||
@@ -136,7 +136,7 @@ Confirm exact invocation by reading `hermes_cli/main.py:1034` area (agent will d
|
||||
|
||||
## Success Metric
|
||||
|
||||
From a fresh Windows machine: install Node 20, `npm install -g @codename-11/hermes-tui-remote` (or equivalent), `hermes --remote wss://docker-server.ts.net:8767 --pair ABC123`, and do a full interactive session (prompt → tool use → image paste → approval) without a single "feature missing" or "protocol mismatch" error.
|
||||
From a fresh Windows machine: install Node 20, `npm install -g @codename-11/hermes-tui-remote` (or equivalent), `hermes --remote wss://hermes-host.tailnet.ts.net:8767 --pair ABC123`, and do a full interactive session (prompt → tool use → image paste → approval) without a single "feature missing" or "protocol mismatch" error.
|
||||
|
||||
## Follow-up Ideas (explicit non-MVP)
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ python -m unittest plugin.tests.test_realtime_voice_routes plugin.tests.test_voi
|
||||
1. Land capability guard first if OpenAI remains selectable before native support is ready.
|
||||
2. Implement the OpenAI provider behind tests.
|
||||
3. Register OpenAI as native only after adapter and broker tests pass.
|
||||
4. Deploy relay-only files to `bailey@docker-server.local`.
|
||||
4. Deploy relay-only files to `you@hermes-host`.
|
||||
5. Restart `hermes-relay.service`.
|
||||
6. Verify `/health`.
|
||||
7. Test Android Realtime Agent with `openai_realtime`, including:
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# Connection Security Indicator — Surfacing, Wording & Docs Plan
|
||||
|
||||
**Status:** Draft for review (no implementation yet — placement decisions pending)
|
||||
**Date:** 2026-06-24
|
||||
**Owner surface:** Android app (UI), user docs, engineering docs
|
||||
**Companion to:** [`docs/plans/2026-06-18-native-secure-routes.md`](2026-06-18-native-secure-routes.md) (Features-vs-Routes split + plugin secure proxy mechanics). That plan owns *how routes work*; **this plan owns how security is communicated** to the user across every surface.
|
||||
**Goal:** Let a user tell, at a glance and without ambiguity, whether their connection to Hermes is encrypted — and by what (TLS, Tailscale/WireGuard, or not at all) — without the app lying or scaring people who are already secure.
|
||||
|
||||
---
|
||||
|
||||
## Bottom line
|
||||
|
||||
Users keep asking "is this secure?" The honest answer today is *"yes, but the app barely tells you, and where it does, it sometimes lies."* The security **model already exists** in code — it's just (a) buried in `Manage → Connections → Advanced`, (b) mislabelled (a Tailscale route is reported as **"Secure — TLS"** when it's actually WireGuard, not TLS), and (c) absent from every at-a-glance surface (the chat status chip, the connection header, the route picker).
|
||||
|
||||
This is a **surfacing + wording + docs** task, not a greenfield feature. We promote the existing computation to a single source of truth, correct the copy, place a glanceable badge on the high-traffic surfaces, add a tap-through "Connection security" explainer, and fix the docs that conflate "Tailscale" with "TLS."
|
||||
|
||||
---
|
||||
|
||||
## What already exists (do not rebuild)
|
||||
|
||||
| Asset | File | What it does |
|
||||
|---|---|---|
|
||||
| Tri-state model | `ui/components/TransportSecurityBadge.kt` — `TransportSecurityState { AllSecure, Mixed, AllInsecure }` | Badge with lock/shield/lock-open icons + 3 size variants. |
|
||||
| Overlay-aware "is this route encrypted" | `ActiveConnectionSections.kt:1233` `isSelectedRouteUrlSecure()` → `isEncryptedOverlayRoute()` (`:1242`) | **Already** treats `role=="tailscale"`, `plugin_proxy`, WireGuard/HTTPS security hints, and `hasSecureProxy()` as encrypted — not just `wss`/`https`. |
|
||||
| Security posture strip | `ActiveConnectionSections.kt:1109` `ActiveCardSecurityPosture` | Renders the badge + "Tailscale detected" + "hardware keystore" + relay-sessions row. **Buried** under the Advanced section. |
|
||||
| Insecure consent | `ui/components/InsecureConnectionAckDialog.kt`; `ConnectionManager.kt:156–325` (`insecureMode`/`isInsecureConnection`, ws:// block) | Threat-model dialog + reason picker; blocks `ws://` unless insecure mode is on. |
|
||||
| TOFU cert pinning | `auth/CertPinStore.kt` (TLS-only, per `host:port`) | Pins on first `wss`/`https` connect. Not surfaced to users. |
|
||||
| Per-endpoint label | `data/Endpoint.kt:136` `displayLabel()` | "LAN" / "Tailscale" / "HTTPS" / "Plugin proxy". |
|
||||
|
||||
**The three concrete defects to fix:**
|
||||
1. **Buried** — the only real security readout lives below `Advanced` on the Manage tab. Most users never see it.
|
||||
2. **The "TLS lie"** — `resolveStateAppearance(AllSecure)` hardcodes the label **"Secure — TLS"** even when the secure-ness comes from Tailscale/WireGuard (`isEncryptedOverlayRoute` returned true for a `ws://` Tailscale route). Saying "TLS" for a non-TLS link is wrong and erodes trust.
|
||||
3. **No glanceable surface** — the chat status chip (`RelayApp.kt` ~`920–975`, `ChatTransportStatusBadge.kt`), the connection card header, and the route picker (`EndpointsCard.kt`) show the *route name* but never its *security*.
|
||||
|
||||
---
|
||||
|
||||
## The hard question: 3 transports → is "secure" even well-defined?
|
||||
|
||||
**You asked: does having 3 potential transports make this hard to call "secure"? Yes — and that's the core design problem.** A single paired connection fans out to several surfaces, each with an **independent** scheme (confirmed in `Endpoint.kt:37–94` + `ConnectionViewModel.kt:746–820`):
|
||||
|
||||
| Surface | Client | Scheme source | Can be plain while others are TLS? |
|
||||
|---|---|---|---|
|
||||
| Gateway chat (`/api/ws`) | `GatewayChatClient` | dashboard URL scheme | yes |
|
||||
| API / sessions (SSE) | `HermesApiClient` | `endpoint.api.tls` | yes |
|
||||
| Dashboard (Manage/voice) | `DashboardApiClient` | `endpoint.dashboard.url` ∨ derived from `api.tls` | yes |
|
||||
| Relay (terminal/bridge/tools) | `ConnectionManager` | `endpoint.relay.url` (`ws`/`wss`) | yes |
|
||||
|
||||
So **a connection is not uniformly secure** — relay can be `ws://` while the API is `https://`. (Concretely: one paired connection can carry API `https://host:8642`, dashboard derived to `https://host:9119`, and relay `ws://host:8767` — secure chat/Manage, plain relay — at the same time.) A single binary "Secure" badge would lie. The existing `AllSecure / Mixed / AllInsecure` rollup is the right instinct; we keep it but make it **honest and overlay-aware**.
|
||||
|
||||
**Decision (proposed):** show a **connection-level rollup for the glance, per-surface truth on tap.**
|
||||
- **Glance badge** = worst-case across the surfaces *actually in use*: all encrypted → secure; some plain → "Mixed"; all plain with no overlay → "Not encrypted."
|
||||
- **Tap → detail sheet** = the per-surface breakdown (Chat/API: 🔒, Relay: ⚠️, …) so power users get the truth without the chip having to.
|
||||
- Crucially, **"encrypted" includes overlay transports** (Tailscale/WireGuard/plugin proxy), not just TLS — because for the user those *are* secure end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## The wording model (the part that fixes the trust problem)
|
||||
|
||||
Reframe from a binary "Secure/Insecure" to **mechanism-first, 4 outcomes**. The key correction: **Tailscale is secure** — WireGuard gives end-to-end encryption + device identity, arguably stronger than TOFU-pinned TLS. Telling a Tailscale user they're "insecure/plain" is both wrong and the likely reason they keep asking.
|
||||
|
||||
| State | When | Icon | Chip copy | Tone |
|
||||
|---|---|---|---|---|
|
||||
| **TLS** | every in-use surface is `wss`/`https` | 🔒 Lock | `Encrypted · TLS` | green |
|
||||
| **Private network** | plain scheme, but route is Tailscale / WireGuard / plugin proxy | 🛡️ Shield | `Encrypted · Tailscale` (or `· WireGuard` / `· Proxy`) | green |
|
||||
| **Mixed** | some surfaces encrypted, some plain (a secure fallback exists) | 🛡️ Shield | `Mixed routes` | amber |
|
||||
| **Not encrypted** | plain `ws`/`http`, no overlay | ⚠️ Lock-open | `Not encrypted · LAN` | amber→red by context |
|
||||
|
||||
Notes:
|
||||
- Both 🔒 and 🛡️ are **green/"secure"** — only true plaintext-without-overlay is a warning. This is the single most important copy change.
|
||||
- Keep "Plain"/"Not encrypted" (never a blank); avoid the word "Insecure" in the chip (reserve it for the consent dialog where the threat model is explained).
|
||||
- The detail sheet spells out the distinction in one line each: *"TLS — encrypted to this server's certificate (pinned on first connect)."* / *"Tailscale — encrypted by your tailnet (WireGuard), not TLS."* / *"Not encrypted — only safe on a network you fully trust."*
|
||||
- **Code change:** replace the hardcoded `"Secure — TLS"` label (`TransportSecurityBadge.kt:219`) with mechanism-derived copy, and split `AllSecure` into `Tls` vs `Overlay` so the badge can say which.
|
||||
|
||||
---
|
||||
|
||||
## Placement audit & recommendation
|
||||
|
||||
Full surface inventory in the appendix. Recommended placements, highest-traffic first:
|
||||
|
||||
### P1 — Chat bottom status chip (the one everyone sees)
|
||||
`RelayApp.kt` ~`920–975`, beside `ChatTransportStatusBadge` + route label. Today: `⚡ Gateway · Tailscale gpt-5.5 / profile: default`. Add a leading security glyph:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ⚡ Gateway 🛡️ Tailscale gpt-5.5 / profile: default │ ← encrypted via Tailscale (green shield)
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ⚡ Gateway 🔒 TLS gpt-5.5 / profile: default │ ← encrypted via TLS (green lock)
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ⚡ Gateway ⚠️ Not encrypted gpt-5.5 / profile: default │ ← plain LAN, no overlay (amber)
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
Glyph replaces/precedes the bare route word so "Tailscale" now reads as *secure-Tailscale*. Tap the chip → **Connection security** detail sheet.
|
||||
|
||||
### P2 — Connection card header (Manage → Connections)
|
||||
`ActiveConnectionSections.kt` card header — add the same badge next to the `Active` pill so the connection list communicates security without expanding Advanced. Promotes the existing `ActiveCardSecurityPosture` logic up out of the Advanced fold.
|
||||
|
||||
### P3 — Route picker (`EndpointsCard.kt`)
|
||||
Per-route security glyph on each candidate row, so when a user switches routes they see which are encrypted *before* committing:
|
||||
```
|
||||
○ LAN ⚠️ Not encrypted 192.168.x.x · Probe ✓
|
||||
● Tailscale 🛡️ Encrypted 100.x.y.z · Active
|
||||
○ Public 🔒 TLS <host>.ts.net · Probe ✓
|
||||
```
|
||||
|
||||
### P4 — "Connection security" detail sheet (new, the tap target for P1/P2)
|
||||
A small bottom sheet that is the single place the per-surface truth + the explainer lives:
|
||||
```
|
||||
Connection security — <your server>
|
||||
────────────────────────────────────
|
||||
Overall 🛡️ Encrypted (Tailscale)
|
||||
|
||||
Chat (gateway) 🛡️ Tailscale http://100.x.y.z:9119
|
||||
API / sessions 🛡️ Tailscale http://100.x.y.z:8642
|
||||
Relay tools 🛡️ Tailscale ws://100.x.y.z:8767
|
||||
────────────────────────────────────
|
||||
🛡️ Tailscale encrypts this with WireGuard (not TLS).
|
||||
Cert pinning applies only to TLS routes.
|
||||
[ Learn about connection security → ] (docs link)
|
||||
```
|
||||
|
||||
> **For your review:** P1 + P4 are the must-haves (glance + truth-on-tap). P2/P3 are high-value but optional for a first cut. The detail sheet is also the natural home for the **TOFU pin** ("Server identity pinned ✓") and the hardware-keystore line that currently sit in the buried posture strip.
|
||||
|
||||
---
|
||||
|
||||
## Secure proxy: status and how it fits
|
||||
|
||||
The **plugin secure proxy** (the "Secure proxy — Not advertised" row) is a **stub today**: the Android side models it (`Endpoint.kt` `ProxyEndpoint`, `plugin_proxy` role, `hasSecureProxy()`), and `isEncryptedOverlayRoute()` already treats it as encrypted — but **the relay has no proxy-forward implementation, pairing never emits a `plugin_proxy` candidate, and no cert/pin is generated.** Enabling it end-to-end is the unbuilt **Phase 4** of `2026-06-18-native-secure-routes.md` (relay HTTP-forward routes + `RELAY_SSL_*` cert + pairing emission; ~2–3 wk).
|
||||
|
||||
**Implication for this plan:** the indicator must **not block** on the proxy. We design the wording/placement so that *when* a `plugin_proxy` route is advertised it slots in as a 🔒 **TLS (pinned)** route automatically (it already would, via `isEncryptedOverlayRoute`). Until then it stays honestly "Not advertised." Recommend a separate spike to stand it up + test on the server (tracked in `TODO.md`), independent of this UX work.
|
||||
|
||||
---
|
||||
|
||||
## Documentation plan
|
||||
|
||||
The docs currently **conflate "Tailscale" with "TLS/secure"** in several places — fixing this is half the user-facing win.
|
||||
|
||||
**New page:** `user-docs/architecture/connection-security.md` — "Is my connection secure?" Covers: `ws`/`wss` & `http`/`https`; what Tailscale actually does (WireGuard VPN, encrypted + identity, *plus* optional Serve-HTTPS); TLS + TOFU pinning; the per-surface model; how to read the in-app badge; how to get a TLS route (Tailscale Serve `--https`, reverse proxy, or the future plugin proxy). Add to the `/architecture/` sidebar after `security.md`. (Pairs 1:1 with the in-app detail-sheet "Learn more" link.)
|
||||
|
||||
**Conflation fixes (call out "WireGuard ≠ TLS, both are secure"):**
|
||||
- `docs/decisions.md` §15 (`:441` TTL `wss or Tailscale → 30d`) and §24 — annotate that Tailscale's security is WireGuard, separate from `wss`.
|
||||
- `user-docs/architecture/security.md:62–69` — split "Tailscale (VPN + optional managed TLS)" from "reverse proxy (TLS only)."
|
||||
- `user-docs/guide/remote-access.md:27–32, 70–84` — distinguish *who terminates TLS* from *Tailscale provides the network*.
|
||||
- Document TOFU pinning for users for the first time (currently code-only).
|
||||
|
||||
---
|
||||
|
||||
## Open decisions (your call before implementation)
|
||||
|
||||
1. **Is "plain over Tailscale" green or amber?** Recommendation: **green 🛡️ "Encrypted · Tailscale"** (WireGuard is genuinely secure). This is the crux of the trust fix. (Alternative: amber, treating only TLS as fully green — more conservative, but keeps confusing Tailscale users.)
|
||||
2. **Glance scope:** connection-rollup badge + per-surface on tap (recommended), vs. always show per-surface inline (busier).
|
||||
3. **First-cut scope:** P1 (chat chip) + P4 (detail sheet) + wording fix + docs page — vs. also P2/P3 in the same PR.
|
||||
4. **Word choice:** "Encrypted" vs "Secure" vs "Private" for the overlay state. Recommendation: **"Encrypted · <mechanism>"** (concrete, non-marketing).
|
||||
5. **Proxy:** confirm we keep it out of scope here (separate Phase-4 spike).
|
||||
|
||||
---
|
||||
|
||||
## Implementation tiers (after decisions land)
|
||||
|
||||
> Scope: **A** = ship-now UX · **Doc** = docs · effort **S/M/L**.
|
||||
|
||||
### A1 — Single source of truth: `ConnectionSecurity` model · M
|
||||
Lift `isSelectedRouteUrlSecure`/`isEncryptedOverlayRoute` + the per-surface URL scheme reads into a ViewModel-exposed `StateFlow<ConnectionSecurity>` (`{ overall: Tls|Overlay|Mixed|Plain, perSurface: Map<Surface, SecurityKind>, mechanism: String }`). Every surface reads this one flow.
|
||||
**Files:** new `viewmodel/ConnectionSecurity.kt`; `ConnectionViewModel.kt`; refactor `ActiveConnectionSections.kt:1109–1254`.
|
||||
|
||||
### A2 — Fix the wording / split `AllSecure` into Tls vs Overlay · S
|
||||
Replace hardcoded `"Secure — TLS"`; mechanism-derived copy; new state for overlay. Pure `TransportSecurityBadge.kt` change + tests.
|
||||
|
||||
### A3 — P1 chat status chip glyph · S
|
||||
Add the security glyph to the chat bottom strip; tap → detail sheet. **Files:** `RelayApp.kt`, `ChatTransportStatusBadge.kt`.
|
||||
|
||||
### A4 — P4 "Connection security" detail sheet · M
|
||||
New bottom sheet; per-surface rows + explainer + TOFU/keystore lines + docs link. **Files:** new `ui/components/ConnectionSecuritySheet.kt`.
|
||||
|
||||
### A5 — P2 header badge + P3 route-picker glyphs · M (optional first cut)
|
||||
**Files:** `ActiveConnectionSections.kt`, `EndpointsCard.kt`.
|
||||
|
||||
### Doc1 — `connection-security.md` + conflation fixes · M
|
||||
New user-docs page + the four conflation edits + TOFU documentation.
|
||||
|
||||
### Spike — stand up & test the plugin secure proxy · L (separate, not blocking)
|
||||
Phase 4 of `2026-06-18-native-secure-routes.md`. Tracked in `TODO.md`.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — full UI surface inventory
|
||||
|
||||
| Surface | File:area | Shows today | Security data available |
|
||||
|---|---|---|---|
|
||||
| Chat status chip | `RelayApp.kt` ~920–975; `ChatTransportStatusBadge.kt` | transport tier + route + model | route role + per-surface URL schemes |
|
||||
| Connection card header | `ActiveConnectionSections.kt` (card header) | name + Active + route summary | full per-surface |
|
||||
| Status rows (API/Dashboard/Relay/Session) | `ActiveConnectionSections.kt:108–219` | reachable/connected + "Connected · Tailscale" | role known, security not rendered |
|
||||
| Feature rows (incl. "Secure proxy") | `ActiveConnectionSections.kt:227–380` | Ready/Configured/Not advertised | proxy advertise flag |
|
||||
| Security posture strip | `ActiveConnectionSections.kt:1109–1231` | **the existing badge** (buried under Advanced) | full (this is the source to promote) |
|
||||
| Route picker | `EndpointsCard.kt:79–194` | per-route role + health | per-candidate scheme |
|
||||
| Insecure toggle + ack | `ActiveConnectionSections.kt:806–866`; `InsecureConnectionAckDialog.kt` | warning + reason picker | `isInsecureConnection` |
|
||||
| Connection info sheet | `ConnectionInfoSheet.kt` | session state | session relay URL |
|
||||
| Pair wizard confirm | `OnboardingScreen.kt` / pairing flow | route candidates | candidate schemes (good place for per-route glyph at commit time) |
|
||||
@@ -83,13 +83,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
|
||||
Paste into Play Console → **What's new** (≤500 characters):
|
||||
|
||||
```
|
||||
v1.2.1 — Polish & control.
|
||||
v1.2.3 — Connection crash fix.
|
||||
|
||||
• Lock the app to a single agent profile and hide the rest.
|
||||
• In-app "What's New" with current and past release notes.
|
||||
• Diagnostics with clean error titles and one-tap reporting.
|
||||
• A tasteful, dismissable "update available" nudge.
|
||||
• Voice fixes: Stop halts speech instantly, steadier hold-to-talk, a more readable overlay, and chosen voices apply in Auto mode.
|
||||
• Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS). Connecting over a secured connection is now stable. Plain local-network connections were never affected.
|
||||
```
|
||||
|
||||
## Category
|
||||
|
||||
+11
-11
@@ -985,7 +985,7 @@ quota.
|
||||
## Tested Relay Command Flow
|
||||
|
||||
These commands were used on 2026-05-18 to verify the relay-owned output path
|
||||
against the Docker-server deployment target.
|
||||
against the hermes-host deployment target.
|
||||
|
||||
```powershell
|
||||
# Local repo checks
|
||||
@@ -996,20 +996,20 @@ python -m compileall plugin\relay plugin\voice_lab
|
||||
.\gradlew.bat :app:installSideloadDebug
|
||||
|
||||
# Remote relay checks
|
||||
ssh bailey@172.16.24.250 "cd ~/.hermes/hermes-relay && ~/.hermes/hermes-agent/venv/bin/python -m compileall plugin/relay plugin/voice_lab"
|
||||
ssh bailey@172.16.24.250 "cd ~/.hermes/hermes-relay && ~/.hermes/hermes-agent/venv/bin/python -m unittest plugin.tests.test_voice_output_routes plugin.tests.test_voice_lab -v"
|
||||
ssh bailey@172.16.24.250 "systemctl --user restart hermes-relay.service"
|
||||
ssh bailey@172.16.24.250 "curl -fsS http://127.0.0.1:8767/health"
|
||||
ssh you@hermes-host "cd ~/.hermes/hermes-relay && ~/.hermes/hermes-agent/venv/bin/python -m compileall plugin/relay plugin/voice_lab"
|
||||
ssh you@hermes-host "cd ~/.hermes/hermes-relay && ~/.hermes/hermes-agent/venv/bin/python -m unittest plugin.tests.test_voice_output_routes plugin.tests.test_voice_lab -v"
|
||||
ssh you@hermes-host "systemctl --user restart hermes-relay.service"
|
||||
ssh you@hermes-host "curl -fsS http://127.0.0.1:8767/health"
|
||||
```
|
||||
|
||||
Live relay smoke used the configured server token without printing secrets:
|
||||
|
||||
```bash
|
||||
set -a
|
||||
. /home/bailey/.hermes/.env >/dev/null 2>&1 || true
|
||||
. $HOME/.hermes/.env >/dev/null 2>&1 || true
|
||||
set +a
|
||||
cd /home/bailey/.hermes/hermes-relay
|
||||
/home/bailey/.hermes/hermes-agent/venv/bin/python - <<'PY'
|
||||
cd $HOME/.hermes/hermes-relay
|
||||
$HOME/.hermes/hermes-agent/venv/bin/python - <<'PY'
|
||||
import asyncio, base64, json, os, time, aiohttp
|
||||
|
||||
BASE = "http://127.0.0.1:8767"
|
||||
@@ -1056,7 +1056,7 @@ PY
|
||||
|
||||
Expected shape for the successful xAI/Grok TTS smoke is
|
||||
`provider=xai_tts`, `model=xai-tts`, `voice=eve`, nonzero `audio_bytes`, and
|
||||
`voice.response.done`. The 2026-05-18 Docker-server smoke produced first audio
|
||||
`voice.response.done`. The 2026-05-18 hermes-host smoke produced first audio
|
||||
in roughly 330 ms and completed a short tool-status phrase in roughly 605 ms.
|
||||
|
||||
### Live Phone Smoke
|
||||
@@ -1081,7 +1081,7 @@ On the phone:
|
||||
1. Unlock the device.
|
||||
2. Open Hermes Relay sideload build `0.8.0-sideload`.
|
||||
3. Confirm it reconnects to the active relay route, for example
|
||||
`ws://172.16.24.250:8767/ws` or the configured Tailscale route.
|
||||
`ws://192.168.1.100:8767/ws` or the configured Tailscale route.
|
||||
4. Start tap-to-talk and say: `check the relay status`.
|
||||
5. Wait for the spoken status/tool narration and assistant reply.
|
||||
6. Interrupt while it is speaking to verify barge-in cancellation/resume.
|
||||
@@ -1105,7 +1105,7 @@ adb logcat -d -v time |
|
||||
Select-String -Pattern "VoiceViewModel|RelayVoiceClient|voice/output|voice.response|voice.audio|voice.session|voice.replay|session.resume|replayed|RealtimePcmPlayer|BargeIn|transcribe|synthesize|auth.ok|sendMessage|tool|ConnectionManager|endpoint fallback|probeAndReconnect" |
|
||||
Select-Object -Last 260
|
||||
|
||||
ssh bailey@docker-server.local 'journalctl --user -u hermes-relay.service --since "10 minutes ago" --no-pager | grep -E "Client connected|Client disconnected|voice.output|voice/output|voice/realtime|voice.session|voice.replay|session.resume|detached|resumed|resume_failed|voice/config|voice/transcribe|voice/synthesize|ERROR|Traceback" | tail -160'
|
||||
ssh you@hermes-host 'journalctl --user -u hermes-relay.service --since "10 minutes ago" --no-pager | grep -E "Client connected|Client disconnected|voice.output|voice/output|voice/realtime|voice.session|voice.replay|session.resume|detached|resumed|resume_failed|voice/config|voice/transcribe|voice/synthesize|ERROR|Traceback" | tail -160'
|
||||
```
|
||||
|
||||
Pass criteria:
|
||||
|
||||
+110
-5
@@ -41,7 +41,11 @@ Source: `plugin/relay/server.py:2649-2889` (`handle_ws`, `_authenticate`).
|
||||
"device_id": "android-device-uuid",
|
||||
"ttl_seconds": 2592000,
|
||||
"grants": {"chat": 2592000, "terminal": 604800, "bridge": 604800, "voice:stt": 2592000},
|
||||
"session_token": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
"session_token": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"supports": {
|
||||
"typed_stream_events": true,
|
||||
"event_schema_version": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -53,6 +57,7 @@ Source: `plugin/relay/server.py:2649-2889` (`handle_ws`, `_authenticate`).
|
||||
- `device_id` — unique persistent identifier.
|
||||
- `ttl_seconds` — requested session lifetime; `0` means never expire. Ignored if pairing code carried pre-set metadata from host.
|
||||
- `grants` — per-channel seconds-from-now. Keys include `chat`, `terminal`, `bridge`, `tui`, `voice:config`, `voice:stt`, `voice:tts`, and `voice:realtime`.
|
||||
- `supports` — optional capability negotiation. `typed_stream_events: true` with `event_schema_version: 1` opts the client into first-class `chat`/`stream.event` envelopes (§3.3.2). Omit it or set it false for legacy text/final-response mode.
|
||||
|
||||
Source: `plugin/relay/server.py:2804-2850`.
|
||||
|
||||
@@ -211,9 +216,109 @@ Sources: `plugin/relay/channels/bridge.py`, `app/src/main/kotlin/.../network/han
|
||||
|
||||
### 3.3 Chat
|
||||
|
||||
**Note:** Chat does **not** traverse the relay. It rides the vanilla upstream Hermes surfaces — the dashboard `/api/ws` gateway transport (live thinking) when Manage auth is ready, falling back to the API server's SSE routes.
|
||||
**Purpose:** Native chat turn streaming and session listing.
|
||||
**Direction:** Client → Server (`chat.send`, `chat.sessions.list`); Server → Client (legacy chat envelopes or typed stream events).
|
||||
**Handler:** `plugin/relay/channels/chat.py`.
|
||||
|
||||
Relay involvement is limited to session management routes (`/api/sessions/*`) for create/list/delete/extend. See hermes-relay CLAUDE.md §"Upstream Hermes API Reference" for the endpoint catalog.
|
||||
Modern Android/Desktop usually talk directly to Hermes dashboard/API-server for chat, but Relay also exposes a chat channel for paired native clients that need a single WSS route. Relay proxies `/api/sessions/{id}/chat/stream` SSE and preserves old text-first behavior unless the client explicitly advertises typed stream support in `system/auth.payload.supports`.
|
||||
|
||||
#### 3.3.1 Legacy chat envelopes
|
||||
|
||||
Clients that do not send `supports.typed_stream_events=true` receive the historical flattened messages:
|
||||
|
||||
| Type | Payload |
|
||||
|------|---------|
|
||||
| `chat.session` | `{session_id,title,model}` after Relay creates a session |
|
||||
| `chat.delta` | `{session_id,message_id,delta}` assistant text only |
|
||||
| `chat.progress` | `{session_id,message_id,delta}` subdued thinking/progress text |
|
||||
| `chat.tool.started` | `{tool_name,tool_call_id?,preview,args}` |
|
||||
| `chat.tool.completed` | `{tool_name,tool_call_id?,result_preview,success}` |
|
||||
| `chat.tool.failed` | `{tool_name,tool_call_id?,error}` |
|
||||
| `chat.turn.completed` | one assistant turn finished but run may continue |
|
||||
| `chat.completed` | whole run/stream finished |
|
||||
| `chat.error` | `{message}` |
|
||||
|
||||
This mode deliberately drops unknown/informational Hermes SSE events so older clients continue to work without UI changes.
|
||||
|
||||
#### 3.3.2 Typed stream.event mode
|
||||
|
||||
Capability negotiation:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "auth",
|
||||
"payload": {
|
||||
"session_token": "...",
|
||||
"supports": {
|
||||
"typed_stream_events": true,
|
||||
"event_schema_version": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When negotiated, each Hermes/API-server SSE event is forwarded on the chat channel as a Relay envelope whose payload is the versioned stream envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel": "chat",
|
||||
"type": "stream.event",
|
||||
"id": "<uuid>",
|
||||
"payload": {
|
||||
"type": "stream.event",
|
||||
"schema_version": 1,
|
||||
"session_id": "sess_123",
|
||||
"run_id": "run_123",
|
||||
"seq": 42,
|
||||
"event": "tool.started",
|
||||
"ts": "2026-06-05T00:00:00Z",
|
||||
"payload": {
|
||||
"tool_name": "terminal",
|
||||
"call_id": "call_123",
|
||||
"preview": "npm test"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Stable top-level fields:
|
||||
|
||||
| Field | Stability | Notes |
|
||||
|-------|-----------|-------|
|
||||
| `type` | stable | always `stream.event` |
|
||||
| `schema_version` | stable | v1 for this document. New incompatible shapes must increment. |
|
||||
| `session_id` | stable | Hermes chat session id, copied from upstream or Relay-created session |
|
||||
| `run_id` | stable nullable | upstream run id when present |
|
||||
| `seq` | stable | monotonic per Relay stream (`session_id + run_id + request id`) when upstream does not supply one; clients use it for order/de-dupe |
|
||||
| `event` | stable | event family below |
|
||||
| `ts` | stable | ISO-8601 UTC string; Relay-generated when upstream omits timestamp |
|
||||
| `payload` | event-specific | JSON object; unknown fields are preview-only unless documented by the upstream Hermes SSE contract |
|
||||
|
||||
Stable event families forwarded by Relay v1:
|
||||
|
||||
`session.created`, `run.started`, `message.started`, `assistant.delta`, `tool.progress`, `tool.pending`, `tool.started`, `tool.completed`, `tool.failed`, `memory.updated`, `skill.loaded`, `artifact.created`, `assistant.completed`, `run.completed`, `error`, `done`.
|
||||
|
||||
Relay-specific events must be namespaced: `relay.connection.*`, `relay.resume.*`, `relay.client_ack`.
|
||||
|
||||
Rendering guidance:
|
||||
|
||||
| Event | Native rendering |
|
||||
|-------|------------------|
|
||||
| `assistant.delta` | append to assistant bubble incrementally |
|
||||
| `tool.progress` | subdued progress/thinking row, not assistant text |
|
||||
| `tool.pending`/`tool.started`/`tool.completed`/`tool.failed` | collapsible tool card lifecycle |
|
||||
| `artifact.created` | tappable/downloadable attachment row; payload may contain `url`, `path`, `title`, or a preview |
|
||||
| `memory.updated`/`skill.loaded` | low-noise timeline chip/badge |
|
||||
| `assistant.completed` | finish current assistant turn; run may continue |
|
||||
| `run.completed`/`done` | explicit terminal completion state |
|
||||
| `error` | explicit error/partial/interrupted affordance |
|
||||
|
||||
Reconnect/resume v1: Relay preserves in-order delivery on a live WebSocket and emits sequence numbers. Guaranteed replay/resume is not implemented for chat v1; clients should de-dupe by `(run_id || session_id, seq)` after reconnect and treat missing sequence gaps as best-effort live-stream loss. Future guaranteed resume belongs under `relay.resume.*`.
|
||||
|
||||
Payload safety: Relay redacts common secret-shaped keys (`token`, `api_key`, `authorization`, `password`, `secret`) and truncates large result-like fields to previews before sending typed events. Native clients must still treat payloads as previews, not as an authority for full tool results.
|
||||
|
||||
Golden fixture: `docs/fixtures/typed-stream-v1.jsonl` contains an ordered tool-using stream for native renderer tests and manual smoke.
|
||||
|
||||
### 3.4 Terminal
|
||||
|
||||
@@ -717,12 +822,12 @@ Top-level `key` is the Hermes API bearer used for direct chat/session HTTP; the
|
||||
```json
|
||||
{
|
||||
"hermes": 3,
|
||||
"host": "172.16.24.250",
|
||||
"host": "192.168.1.100",
|
||||
"port": 8642,
|
||||
"key": "<api_key>",
|
||||
"tls": false,
|
||||
"relay": {
|
||||
"url": "ws://172.16.24.250:8767",
|
||||
"url": "ws://192.168.1.100:8767",
|
||||
"code": "ABC123",
|
||||
"ttl_seconds": 604800,
|
||||
"transport_hint": "ws"
|
||||
|
||||
@@ -42,7 +42,7 @@ API_SERVER_PORT=8645
|
||||
API_SERVER_KEY=<same key as the paired Android connection>
|
||||
```
|
||||
|
||||
Use a distinct port per running profile, then start that profile's Hermes gateway/API service with your normal Hermes service manager, for example `hermes -p mizu gateway start` or the equivalent container/supervisor entry. Set `RELAY_WEBAPI_URL` on the relay service to the phone-reachable base Hermes API URL, for example `http://172.16.24.250:8642`; this lets the relay rewrite local profile binds (`127.0.0.1`, `localhost`, `0.0.0.0`, `::1`) to that same host/scheme while preserving the profile API port. Android also defensively rewrites loopback profile URLs against the active Connection API URL so stale or host-local profile payloads do not make the phone dial its own `127.0.0.1`.
|
||||
Use a distinct port per running profile, then start that profile's Hermes gateway/API service with your normal Hermes service manager, for example `hermes -p mizu gateway start` or the equivalent container/supervisor entry. Set `RELAY_WEBAPI_URL` on the relay service to the phone-reachable base Hermes API URL, for example `http://192.168.1.100:8642`; this lets the relay rewrite local profile binds (`127.0.0.1`, `localhost`, `0.0.0.0`, `::1`) to that same host/scheme while preserving the profile API port. Android also defensively rewrites loopback profile URLs against the active Connection API URL so stale or host-local profile payloads do not make the phone dial its own `127.0.0.1`.
|
||||
|
||||
If a profile does not advertise a running API server, Android can still select it, but the behavior is compatibility fallback: the app sends that profile's `model` and `SOUL.md` as request overrides on the active Connection API server. That fallback does not isolate profile memory, sessions, tools, provider auth, or cron jobs.
|
||||
|
||||
|
||||
+2
-2
@@ -230,12 +230,12 @@ Biometric gate on the app side for terminal access (fingerprint/face) remains pl
|
||||
```json
|
||||
{
|
||||
"hermes": 3,
|
||||
"host": "172.16.24.250",
|
||||
"host": "192.168.1.100",
|
||||
"port": 8642,
|
||||
"key": "api-bearer-token",
|
||||
"tls": false,
|
||||
"relay": {
|
||||
"url": "ws://172.16.24.250:8767",
|
||||
"url": "ws://192.168.1.100:8767",
|
||||
"code": "ABCD12",
|
||||
"ttl_seconds": 2592000,
|
||||
"grants": { "terminal": 2592000, "bridge": 604800 },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.2.1"
|
||||
appVersionCode = "15"
|
||||
appVersionName = "1.2.3"
|
||||
appVersionCode = "17"
|
||||
agp = "9.2.1"
|
||||
kotlin = "2.4.0"
|
||||
compose-bom = "2026.06.00"
|
||||
|
||||
+1
-1
@@ -708,7 +708,7 @@ else
|
||||
# Was the service already running? If yes we MUST `restart` it
|
||||
# explicitly — `enable --now` is a no-op on already-active services
|
||||
# and the editable-install code refresh would never reach the live
|
||||
# process. (Spent way too long debugging this on Docker-Server
|
||||
# process. (Spent way too long debugging this on hermes-host
|
||||
# 2026-04-12 — every install.sh run looked successful but the live
|
||||
# relay kept serving stale code from before the last git pull.)
|
||||
if systemctl --user is-active hermes-relay.service >/dev/null 2>&1; then
|
||||
|
||||
@@ -245,7 +245,7 @@ async def mint_pairing(body: dict[str, Any] = Body(default_factory=dict)) -> Any
|
||||
"""Mint a fresh pairing code + return a signed QR payload.
|
||||
|
||||
Body (all fields optional — relay fills them from its config):
|
||||
- host: "172.16.24.250" API server host the phone will hit
|
||||
- host: "192.168.1.100" API server host the phone will hit
|
||||
(defaults to RelayConfig.webapi_url host,
|
||||
resolved to a LAN-routable IP)
|
||||
- port: 8642 API server port
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -23,8 +24,22 @@ logger = logging.getLogger(__name__)
|
||||
# The WebAPI may use different names than our protocol — this layer
|
||||
# absorbs that difference.
|
||||
_SSE_TYPE_MAP: dict[str, str] = {
|
||||
# Current Hermes API-server event families
|
||||
"assistant.delta": "chat.delta",
|
||||
"tool.progress": "chat.progress",
|
||||
"tool.pending": "chat.tool.started",
|
||||
"tool.started": "chat.tool.started",
|
||||
"tool.completed": "chat.tool.completed",
|
||||
"tool.failed": "chat.tool.failed",
|
||||
"assistant.completed": "chat.turn.completed",
|
||||
"run.completed": "chat.completed",
|
||||
"done": "chat.completed",
|
||||
"error": "chat.error",
|
||||
# Historical aliases
|
||||
"content_delta": "chat.delta",
|
||||
"delta": "chat.delta",
|
||||
"thinking_delta": "chat.progress",
|
||||
"reasoning_delta": "chat.progress",
|
||||
"tool_start": "chat.tool.started",
|
||||
"tool_started": "chat.tool.started",
|
||||
"tool_result": "chat.tool.completed",
|
||||
@@ -32,9 +47,29 @@ _SSE_TYPE_MAP: dict[str, str] = {
|
||||
"content_complete": "chat.completed",
|
||||
"complete": "chat.completed",
|
||||
"completed": "chat.completed",
|
||||
"error": "chat.error",
|
||||
}
|
||||
|
||||
_TYPED_STREAM_EVENT_NAMES: set[str] = {
|
||||
"session.created",
|
||||
"run.started",
|
||||
"message.started",
|
||||
"assistant.delta",
|
||||
"tool.progress",
|
||||
"tool.pending",
|
||||
"tool.started",
|
||||
"tool.completed",
|
||||
"tool.failed",
|
||||
"memory.updated",
|
||||
"skill.loaded",
|
||||
"artifact.created",
|
||||
"assistant.completed",
|
||||
"run.completed",
|
||||
"error",
|
||||
"done",
|
||||
}
|
||||
|
||||
_STABLE_EVENT_FIELDS = {"type", "event", "session_id", "run_id", "seq", "ts", "timestamp"}
|
||||
|
||||
|
||||
def _make_envelope(
|
||||
msg_type: str,
|
||||
@@ -52,6 +87,83 @@ def _make_envelope(
|
||||
)
|
||||
|
||||
|
||||
def _iso_now() -> str:
|
||||
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _json_preview(value: Any, *, max_chars: int = 1600) -> Any:
|
||||
"""Return a compact JSON-serializable preview suitable for native clients.
|
||||
|
||||
Typed relay stream payloads deliberately avoid forwarding unbounded raw tool
|
||||
results. Dict/list structure is preserved where it is small; otherwise the
|
||||
payload falls back to a truncated string preview.
|
||||
"""
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value if len(value) <= max_chars else value[: max_chars - 1] + "…"
|
||||
if isinstance(value, dict):
|
||||
out: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
lowered = key.lower()
|
||||
if lowered in {"api_key", "authorization", "password", "refresh_token", "session_token", "token", "secret"}:
|
||||
out[key] = "[REDACTED]"
|
||||
elif key in {"result", "output", "stdout", "stderr"}:
|
||||
out[f"{key}_preview"] = _json_preview(item, max_chars=max_chars)
|
||||
else:
|
||||
out[key] = _json_preview(item, max_chars=max_chars)
|
||||
return out
|
||||
if isinstance(value, list):
|
||||
return [_json_preview(item, max_chars=max_chars) for item in value[:20]]
|
||||
return _json_preview(str(value), max_chars=max_chars)
|
||||
|
||||
|
||||
def _normalize_typed_payload(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: _json_preview(value)
|
||||
for key, value in raw.items()
|
||||
if key not in _STABLE_EVENT_FIELDS
|
||||
}
|
||||
|
||||
|
||||
def _typed_stream_envelope(
|
||||
*,
|
||||
event: str,
|
||||
payload: dict[str, Any],
|
||||
session_id: str,
|
||||
run_id: str | None,
|
||||
seq: int,
|
||||
msg_id: str | None = None,
|
||||
ts: str | None = None,
|
||||
) -> str:
|
||||
"""Build the Relay WS wrapper around the versioned stream.event payload."""
|
||||
stream_payload: dict[str, Any] = {
|
||||
"type": "stream.event",
|
||||
"schema_version": 1,
|
||||
"session_id": session_id,
|
||||
"run_id": run_id,
|
||||
"seq": seq,
|
||||
"event": event,
|
||||
"ts": ts or _iso_now(),
|
||||
"payload": payload,
|
||||
}
|
||||
return _make_envelope("stream.event", stream_payload, msg_id)
|
||||
|
||||
|
||||
def _supports_typed_stream_events(capabilities: dict[str, Any] | None) -> bool:
|
||||
if not isinstance(capabilities, dict):
|
||||
return False
|
||||
supports = capabilities.get("supports")
|
||||
if not isinstance(supports, dict):
|
||||
supports = capabilities
|
||||
if supports.get("typed_stream_events") is not True:
|
||||
return False
|
||||
version = supports.get("event_schema_version", supports.get("typed_stream_event_schema_version", 1))
|
||||
return version in (1, "1")
|
||||
|
||||
|
||||
class ChatHandler:
|
||||
"""Handles all ``chat.*`` messages from the phone.
|
||||
|
||||
@@ -62,6 +174,30 @@ class ChatHandler:
|
||||
def __init__(self, webapi_url: str = "http://localhost:8642") -> None:
|
||||
self._webapi_url = webapi_url.rstrip("/")
|
||||
self._http: aiohttp.ClientSession | None = None
|
||||
self._client_capabilities: dict[int, dict[str, Any]] = {}
|
||||
self._stream_seq: dict[str, int] = {}
|
||||
|
||||
def set_client_capabilities(
|
||||
self, ws: web.WebSocketResponse, capabilities: dict[str, Any] | None
|
||||
) -> None:
|
||||
"""Record negotiated client capabilities for this websocket."""
|
||||
if capabilities:
|
||||
self._client_capabilities[id(ws)] = capabilities
|
||||
else:
|
||||
self._client_capabilities.pop(id(ws), None)
|
||||
|
||||
def detach_ws(self, ws: web.WebSocketResponse) -> None:
|
||||
"""Forget per-websocket stream state after disconnect."""
|
||||
ws_id = id(ws)
|
||||
self._client_capabilities.pop(ws_id, None)
|
||||
prefix = f"{ws_id}:"
|
||||
for key in [key for key in self._stream_seq if key.startswith(prefix)]:
|
||||
self._stream_seq.pop(key, None)
|
||||
|
||||
def _next_seq(self, stream_key: str) -> int:
|
||||
seq = self._stream_seq.get(stream_key, 0) + 1
|
||||
self._stream_seq[stream_key] = seq
|
||||
return seq
|
||||
|
||||
async def _get_http(self) -> aiohttp.ClientSession:
|
||||
"""Return (and lazily create) the shared HTTP client session."""
|
||||
@@ -79,8 +215,15 @@ class ChatHandler:
|
||||
|
||||
# ── Dispatcher ───────────────────────────────────────────────────────
|
||||
|
||||
async def handle(self, ws: web.WebSocketResponse, envelope: dict[str, Any]) -> None:
|
||||
async def handle(
|
||||
self,
|
||||
ws: web.WebSocketResponse,
|
||||
envelope: dict[str, Any],
|
||||
client_capabilities: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Route an incoming chat-channel envelope to the right handler."""
|
||||
if client_capabilities is not None:
|
||||
self.set_client_capabilities(ws, client_capabilities)
|
||||
msg_type = envelope.get("type", "")
|
||||
payload = envelope.get("payload", {})
|
||||
msg_id = envelope.get("id")
|
||||
@@ -313,24 +456,42 @@ class ChatHandler:
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
except json.JSONDecodeError:
|
||||
# Some events may be plain text (e.g. "[DONE]")
|
||||
# Some events may be plain text (notably "[DONE]"). Typed-capable
|
||||
# clients still receive a first-class final done event; legacy
|
||||
# clients keep the historical behavior (ignore bare [DONE]).
|
||||
if data_str.strip() == "[DONE]":
|
||||
if _supports_typed_stream_events(self._client_capabilities.get(id(ws))):
|
||||
await self._emit_typed_stream_event(
|
||||
ws,
|
||||
raw_type="done",
|
||||
data={"state": "final"},
|
||||
session_id=session_id,
|
||||
msg_id=msg_id,
|
||||
)
|
||||
return
|
||||
logger.debug("Non-JSON SSE data: %s", data_str[:100])
|
||||
return
|
||||
|
||||
# Determine the Hermes SSE event type. Try:
|
||||
# 1. The explicit ``event:`` line from SSE
|
||||
# 2. A ``type`` field inside the JSON data
|
||||
raw_type = sse_event_type or data.get("type", "")
|
||||
# 2. A ``type`` or ``event`` field inside the JSON data
|
||||
raw_type = sse_event_type or data.get("type") or data.get("event") or ""
|
||||
|
||||
# Map to our protocol type
|
||||
if _supports_typed_stream_events(self._client_capabilities.get(id(ws))):
|
||||
await self._emit_typed_stream_event(
|
||||
ws, raw_type=raw_type, data=data, session_id=session_id, msg_id=msg_id
|
||||
)
|
||||
return
|
||||
|
||||
# Map to our legacy protocol type for text-only clients.
|
||||
proto_type = _SSE_TYPE_MAP.get(raw_type)
|
||||
|
||||
if proto_type is None:
|
||||
# If we don't recognize the type, log it and skip
|
||||
# Unknown informational events are intentionally dropped in legacy
|
||||
# text mode to preserve old clients' behavior. Typed-capable clients
|
||||
# receive them losslessly above.
|
||||
logger.debug(
|
||||
"Unmapped SSE event type %r — forwarding raw data", raw_type
|
||||
"Unmapped SSE event type %r — skipped for legacy chat client", raw_type
|
||||
)
|
||||
return
|
||||
|
||||
@@ -342,6 +503,41 @@ class ChatHandler:
|
||||
except ConnectionResetError:
|
||||
logger.warning("WebSocket closed while sending %s", proto_type)
|
||||
|
||||
async def _emit_typed_stream_event(
|
||||
self,
|
||||
ws: web.WebSocketResponse,
|
||||
*,
|
||||
raw_type: str,
|
||||
data: dict[str, Any],
|
||||
session_id: str,
|
||||
msg_id: str | None,
|
||||
) -> None:
|
||||
event = raw_type or data.get("type") or data.get("event") or "assistant.delta"
|
||||
if event not in _TYPED_STREAM_EVENT_NAMES and not event.startswith("relay."):
|
||||
logger.debug("Forwarding preview typed stream event: %s", event)
|
||||
run_id = data.get("run_id") if isinstance(data.get("run_id"), str) else None
|
||||
stream_key = f"{id(ws)}:{session_id}:{msg_id or 'no-msg'}"
|
||||
seq = data.get("seq")
|
||||
if not isinstance(seq, int):
|
||||
seq = self._next_seq(stream_key)
|
||||
ts_raw = data.get("ts") or data.get("timestamp")
|
||||
ts = ts_raw if isinstance(ts_raw, str) else None
|
||||
payload = _normalize_typed_payload(data)
|
||||
try:
|
||||
await ws.send_str(
|
||||
_typed_stream_envelope(
|
||||
event=event,
|
||||
payload=payload,
|
||||
session_id=str(data.get("session_id") or session_id),
|
||||
run_id=run_id,
|
||||
seq=seq,
|
||||
msg_id=msg_id,
|
||||
ts=ts,
|
||||
)
|
||||
)
|
||||
except ConnectionResetError:
|
||||
logger.warning("WebSocket closed while sending typed stream event %s", event)
|
||||
|
||||
@staticmethod
|
||||
def _build_payload(
|
||||
proto_type: str,
|
||||
@@ -366,12 +562,37 @@ class ChatHandler:
|
||||
if proto_type == "chat.tool.completed":
|
||||
return {
|
||||
"tool_name": data.get("tool_name", data.get("name", "")),
|
||||
"tool_call_id": data.get("tool_call_id", data.get("call_id", "")),
|
||||
"result_preview": data.get(
|
||||
"result_preview", data.get("result", "")
|
||||
),
|
||||
"success": data.get("success", True),
|
||||
}
|
||||
|
||||
if proto_type == "chat.tool.failed":
|
||||
return {
|
||||
"tool_name": data.get("tool_name", data.get("name", "")),
|
||||
"tool_call_id": data.get("tool_call_id", data.get("call_id", "")),
|
||||
"error": data.get("error", data.get("message", "Tool failed")),
|
||||
}
|
||||
|
||||
if proto_type == "chat.progress":
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"message_id": data.get("message_id", data.get("id", "")),
|
||||
"delta": data.get("delta", data.get("thinking_delta", data.get("text", ""))),
|
||||
}
|
||||
|
||||
if proto_type == "chat.turn.completed":
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"message_id": data.get("message_id", data.get("id", "")),
|
||||
"content": data.get("content", ""),
|
||||
"completed": data.get("completed", True),
|
||||
"partial": data.get("partial", False),
|
||||
"interrupted": data.get("interrupted", False),
|
||||
}
|
||||
|
||||
if proto_type == "chat.completed":
|
||||
return {
|
||||
"session_id": session_id,
|
||||
|
||||
+42
-5
@@ -123,6 +123,11 @@ class RelayServer:
|
||||
# In-flight tasks per client (for cancellation on disconnect)
|
||||
self._client_tasks: dict[web.WebSocketResponse, set[asyncio.Task[Any]]] = {}
|
||||
|
||||
# Negotiated websocket capabilities per connected client. Kept outside
|
||||
# Session so reconnects can renegotiate independently and older stored
|
||||
# tokens do not accidentally opt in to a new protocol.
|
||||
self._client_capabilities: dict[web.WebSocketResponse, dict[str, Any]] = {}
|
||||
|
||||
@property
|
||||
def client_count(self) -> int:
|
||||
return len(self._clients)
|
||||
@@ -326,8 +331,8 @@ async def handle_pairing_mint(request: web.Request) -> web.Response:
|
||||
```json
|
||||
{
|
||||
"hermes": 2,
|
||||
"host": "172.16.24.250", "port": 8642, "key": "<api-key>", "tls": false,
|
||||
"relay": {"url": "ws://172.16.24.250:8767", "code": "ABC123",
|
||||
"host": "192.168.1.100", "port": 8642, "key": "<api-key>", "tls": false,
|
||||
"relay": {"url": "ws://192.168.1.100:8767", "code": "ABC123",
|
||||
"ttl_seconds": 604800, "transport_hint": "ws"}
|
||||
}
|
||||
```
|
||||
@@ -340,7 +345,7 @@ async def handle_pairing_mint(request: web.Request) -> web.Response:
|
||||
|
||||
POST /pairing/mint
|
||||
body (all optional — fall back to RelayConfig / local Hermes defaults):
|
||||
- host: "172.16.24.250" API server host override (LAN IP)
|
||||
- host: "192.168.1.100" API server host override (LAN IP)
|
||||
- port: 8642 API server port override
|
||||
- tls: false API server TLS override
|
||||
- api_key: "<token>" API bearer token override (goes in
|
||||
@@ -3222,6 +3227,8 @@ async def handle_ws(request: web.Request) -> web.WebSocketResponse:
|
||||
session_token = await _authenticate(ws, server, remote_ip, request)
|
||||
except _AuthFailed as exc:
|
||||
logger.info("Auth failed from %s: %s", remote_ip, exc)
|
||||
server._client_capabilities.pop(ws, None)
|
||||
server.chat.detach_ws(ws)
|
||||
# WebSocket was already sent an auth.fail message
|
||||
if not ws.closed:
|
||||
await ws.close()
|
||||
@@ -3283,6 +3290,25 @@ def _detect_transport_hint(request: web.Request) -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _extract_client_capabilities(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize optional client capability negotiation from auth payload."""
|
||||
supports = payload.get("supports")
|
||||
if not isinstance(supports, dict):
|
||||
supports = {}
|
||||
typed = supports.get("typed_stream_events") is True
|
||||
version = supports.get("event_schema_version", supports.get("typed_stream_event_schema_version", 1))
|
||||
try:
|
||||
version_int = int(version)
|
||||
except (TypeError, ValueError):
|
||||
version_int = 0
|
||||
return {
|
||||
"supports": {
|
||||
"typed_stream_events": bool(typed and version_int == 1),
|
||||
"event_schema_version": 1 if typed and version_int == 1 else version_int,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _build_auth_ok_payload(
|
||||
session: Session, server: RelayServer
|
||||
) -> dict[str, Any]:
|
||||
@@ -3352,6 +3378,9 @@ async def _authenticate(
|
||||
))
|
||||
|
||||
payload = envelope.get("payload", {})
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
server._client_capabilities[ws] = _extract_client_capabilities(payload)
|
||||
pairing_code = payload.get("pairing_code", "")
|
||||
session_token_attempt = payload.get("session_token", "")
|
||||
refresh_token_attempt = str(payload.get("refresh_token", "") or "").strip()
|
||||
@@ -3510,8 +3539,14 @@ async def _on_message(
|
||||
if channel == "system":
|
||||
await _handle_system(ws, server, envelope)
|
||||
elif channel == "chat":
|
||||
# Run chat handling as a tracked task so we can cancel on disconnect
|
||||
task = asyncio.create_task(server.chat.handle(ws, envelope))
|
||||
# Run chat handling as a tracked task so we can cancel on disconnect.
|
||||
# Capability negotiation happens at system/auth; the chat channel uses
|
||||
# it to decide typed stream.event passthrough vs legacy text envelopes.
|
||||
task = asyncio.create_task(
|
||||
server.chat.handle(
|
||||
ws, envelope, server._client_capabilities.get(ws)
|
||||
)
|
||||
)
|
||||
_track_task(server, ws, task)
|
||||
elif channel == "terminal":
|
||||
task = asyncio.create_task(server.terminal.handle(ws, envelope))
|
||||
@@ -3611,6 +3646,8 @@ async def _on_disconnect(
|
||||
"""Clean up after a client disconnects."""
|
||||
token = server._clients.pop(ws, None)
|
||||
tasks = server._client_tasks.pop(ws, set())
|
||||
server._client_capabilities.pop(ws, None)
|
||||
server.chat.detach_ws(ws)
|
||||
|
||||
# === PHASE3-bridge-server: fail in-flight bridge commands on phone disconnect ===
|
||||
# If this ws was the currently-latched phone, detach_ws flips phone_ws
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Typed chat stream passthrough tests for the Relay chat channel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from typing import Any
|
||||
|
||||
from plugin.relay.channels.chat import ChatHandler
|
||||
|
||||
|
||||
class FakeWebSocket:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
self.sent: list[str] = []
|
||||
|
||||
async def send_str(self, data: str) -> None:
|
||||
self.sent.append(data)
|
||||
|
||||
|
||||
class ChatTypedStreamTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_typed_client_receives_ordered_stream_event_envelopes(self) -> None:
|
||||
handler = ChatHandler()
|
||||
ws = FakeWebSocket()
|
||||
handler.set_client_capabilities(
|
||||
ws, # type: ignore[arg-type]
|
||||
{"supports": {"typed_stream_events": True, "event_schema_version": 1}},
|
||||
)
|
||||
|
||||
await handler._emit_sse_event( # type: ignore[attr-defined]
|
||||
ws, # type: ignore[arg-type]
|
||||
json.dumps(
|
||||
{
|
||||
"type": "assistant.delta",
|
||||
"session_id": "sess-1",
|
||||
"run_id": "run-1",
|
||||
"delta": "Hello",
|
||||
}
|
||||
),
|
||||
"sess-1",
|
||||
"msg-1",
|
||||
None,
|
||||
)
|
||||
await handler._emit_sse_event( # type: ignore[attr-defined]
|
||||
ws, # type: ignore[arg-type]
|
||||
json.dumps(
|
||||
{
|
||||
"type": "tool.started",
|
||||
"session_id": "sess-1",
|
||||
"run_id": "run-1",
|
||||
"tool_name": "terminal",
|
||||
"call_id": "call-1",
|
||||
"args": {"cmd": "echo ok", "api_key": "secret-value"},
|
||||
}
|
||||
),
|
||||
"sess-1",
|
||||
"msg-1",
|
||||
None,
|
||||
)
|
||||
await handler._emit_sse_event( # type: ignore[attr-defined]
|
||||
ws, # type: ignore[arg-type]
|
||||
"[DONE]",
|
||||
"sess-1",
|
||||
"msg-1",
|
||||
None,
|
||||
)
|
||||
|
||||
envelopes = [json.loads(item) for item in ws.sent]
|
||||
self.assertEqual([env["type"] for env in envelopes], ["stream.event", "stream.event", "stream.event"])
|
||||
payloads: list[dict[str, Any]] = [env["payload"] for env in envelopes]
|
||||
self.assertEqual([p["event"] for p in payloads], ["assistant.delta", "tool.started", "done"])
|
||||
self.assertEqual([p["seq"] for p in payloads], [1, 2, 3])
|
||||
self.assertEqual(payloads[0]["schema_version"], 1)
|
||||
self.assertEqual(payloads[0]["session_id"], "sess-1")
|
||||
self.assertEqual(payloads[1]["run_id"], "run-1")
|
||||
self.assertEqual(payloads[1]["payload"]["args"]["api_key"], "[REDACTED]")
|
||||
self.assertEqual(payloads[2]["payload"], {"state": "final"})
|
||||
|
||||
async def test_legacy_client_keeps_flattened_text_mode(self) -> None:
|
||||
handler = ChatHandler()
|
||||
ws = FakeWebSocket()
|
||||
|
||||
await handler._emit_sse_event( # type: ignore[attr-defined]
|
||||
ws, # type: ignore[arg-type]
|
||||
json.dumps({"type": "assistant.delta", "delta": "Hi"}),
|
||||
"sess-legacy",
|
||||
"msg-legacy",
|
||||
None,
|
||||
)
|
||||
await handler._emit_sse_event( # type: ignore[attr-defined]
|
||||
ws, # type: ignore[arg-type]
|
||||
json.dumps({"type": "artifact.created", "url": "https://example.invalid/a"}),
|
||||
"sess-legacy",
|
||||
"msg-legacy",
|
||||
None,
|
||||
)
|
||||
await handler._emit_sse_event( # type: ignore[attr-defined]
|
||||
ws, # type: ignore[arg-type]
|
||||
json.dumps({"type": "tool.failed", "tool_name": "terminal", "error": "boom"}),
|
||||
"sess-legacy",
|
||||
"msg-legacy",
|
||||
None,
|
||||
)
|
||||
await handler._emit_sse_event( # type: ignore[attr-defined]
|
||||
ws, # type: ignore[arg-type]
|
||||
"[DONE]",
|
||||
"sess-legacy",
|
||||
"msg-legacy",
|
||||
None,
|
||||
)
|
||||
|
||||
envelopes = [json.loads(item) for item in ws.sent]
|
||||
self.assertEqual([env["type"] for env in envelopes], ["chat.delta", "chat.tool.failed"])
|
||||
self.assertEqual(envelopes[0]["payload"]["delta"], "Hi")
|
||||
self.assertEqual(envelopes[1]["payload"]["error"], "boom")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
@@ -235,9 +235,9 @@ class PairingMintSchemaTests(AioHTTPTestCase):
|
||||
{
|
||||
"role": "lan",
|
||||
"priority": 0,
|
||||
"api": {"host": "172.16.24.250", "port": 8642, "tls": False},
|
||||
"api": {"host": "192.168.1.100", "port": 8642, "tls": False},
|
||||
"relay": {
|
||||
"url": "ws://172.16.24.250:8767",
|
||||
"url": "ws://192.168.1.100:8767",
|
||||
"transport_hint": "ws",
|
||||
},
|
||||
},
|
||||
@@ -245,12 +245,12 @@ class PairingMintSchemaTests(AioHTTPTestCase):
|
||||
"role": "tailscale",
|
||||
"priority": 1,
|
||||
"api": {
|
||||
"host": "docker-server.tail6f460.ts.net",
|
||||
"host": "hermes-host.tailnet.ts.net",
|
||||
"port": 8642,
|
||||
"tls": True,
|
||||
},
|
||||
"relay": {
|
||||
"url": "wss://docker-server.tail6f460.ts.net:8767",
|
||||
"url": "wss://hermes-host.tailnet.ts.net:8767",
|
||||
"transport_hint": "wss",
|
||||
},
|
||||
},
|
||||
@@ -260,8 +260,8 @@ class PairingMintSchemaTests(AioHTTPTestCase):
|
||||
"plugin.pair._tailscale_status",
|
||||
return_value={
|
||||
"available": True,
|
||||
"hostname": "docker-server.tail6f460.ts.net",
|
||||
"tailscale_ip": "100.71.8.56",
|
||||
"hostname": "hermes-host.tailnet.ts.net",
|
||||
"tailscale_ip": "100.64.0.1",
|
||||
"serve_ports": [],
|
||||
},
|
||||
):
|
||||
@@ -270,9 +270,9 @@ class PairingMintSchemaTests(AioHTTPTestCase):
|
||||
tailscale = qr["endpoints"][1]
|
||||
|
||||
self.assertEqual(qr["hermes"], 3)
|
||||
self.assertEqual(tailscale["api"]["host"], "100.71.8.56")
|
||||
self.assertEqual(tailscale["api"]["host"], "100.64.0.1")
|
||||
self.assertFalse(tailscale["api"]["tls"])
|
||||
self.assertEqual(tailscale["relay"]["url"], "ws://100.71.8.56:8767")
|
||||
self.assertEqual(tailscale["relay"]["url"], "ws://100.64.0.1:8767")
|
||||
self.assertEqual(tailscale["relay"]["transport_hint"], "ws")
|
||||
self.assertEqual(result.get("endpoints"), qr["endpoints"])
|
||||
|
||||
|
||||
@@ -539,7 +539,7 @@ class VoiceRoutesTests(AioHTTPTestCase):
|
||||
async def test_voice_config_returns_providers(self) -> None:
|
||||
sys.modules["tools.tts_tool"]._load_tts_config = lambda: {
|
||||
"provider": "elevenlabs",
|
||||
"voice_id": "XZEfcFyBnzsNJrdvkWdI",
|
||||
"voice_id": "<your-voice-id>",
|
||||
"model": "eleven_turbo_v2_5",
|
||||
}
|
||||
sys.modules["tools.transcription_tools"]._load_stt_config = lambda: {
|
||||
@@ -559,7 +559,7 @@ class VoiceRoutesTests(AioHTTPTestCase):
|
||||
body = await resp.json()
|
||||
self.assertTrue(body["success"])
|
||||
self.assertEqual(body["tts"]["provider"], "elevenlabs")
|
||||
self.assertEqual(body["tts"]["voice_id"], "XZEfcFyBnzsNJrdvkWdI")
|
||||
self.assertEqual(body["tts"]["voice_id"], "<your-voice-id>")
|
||||
self.assertTrue(body["tts"]["enabled"])
|
||||
self.assertEqual(body["stt"]["provider"], "openai")
|
||||
self.assertEqual(body["stt"]["model"], "whisper-1")
|
||||
|
||||
@@ -26,7 +26,7 @@ apply(plugin = "com.meta.spatial.plugin")
|
||||
|
||||
android {
|
||||
namespace = "com.axiomlabs.hermesquest"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.axiomlabs.hermesquest"
|
||||
|
||||
@@ -5,7 +5,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.axiomlabs.hermesrelay.core"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
|
||||
@@ -5,7 +5,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.axiomlabs.hermesrelay.ui"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
param(
|
||||
[string]$RemoteHost = "bailey@docker-server.local",
|
||||
[string]$RemoteHost = "you@hermes-host",
|
||||
[string]$Package = "com.axiomlabs.hermesrelay.sideload",
|
||||
[string]$OutputRoot = "voice-lab-runs\phone-smoke",
|
||||
[int]$SinceMinutes = 15,
|
||||
|
||||
@@ -251,7 +251,7 @@ Expect the agent to render:
|
||||
}
|
||||
```
|
||||
|
||||
The key signal: `stdout` contains **your local hostname**, not the server's. That proves the call routed from hermes → relay → WSS → this machine → shell exec → response back. If the agent instead sees the server's hostname (e.g., `Docker-Server`), the tool is running on the server — check consent and `/desktop/_ping` from the host:
|
||||
The key signal: `stdout` contains **your local hostname**, not the server's. That proves the call routed from hermes → relay → WSS → this machine → shell exec → response back. If the agent instead sees the server's hostname (e.g., `hermes-host`), the tool is running on the server — check consent and `/desktop/_ping` from the host:
|
||||
|
||||
```bash
|
||||
# On the host
|
||||
|
||||
@@ -137,6 +137,7 @@ export default defineConfig({
|
||||
{ text: 'Flavor Differences', link: '/architecture/flavor-differences' },
|
||||
{ text: 'Decisions', link: '/architecture/decisions' },
|
||||
{ text: 'Security', link: '/architecture/security' },
|
||||
{ text: 'Is my connection secure?', link: '/architecture/connection-security' },
|
||||
{ text: 'Privacy', link: '/architecture/privacy' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# Is my connection secure?
|
||||
|
||||
Short answer: **probably yes** — and Hermes-Relay now tells you at a glance, without
|
||||
overstating or understating it.
|
||||
|
||||
This page explains what "encrypted" actually means for your connection, why a Tailscale
|
||||
link is genuinely secure even when it looks like plain `http://`, and how to read the
|
||||
in-app security indicator. If you just want the quick version, jump to
|
||||
[How to read the indicator](#how-to-read-the-in-app-indicator).
|
||||
|
||||
## Plain vs. encrypted: `ws://` vs `wss://`, `http://` vs `https://`
|
||||
|
||||
Every connection uses a URL scheme, and the scheme tells you whether the link is
|
||||
encrypted by **TLS** (the same technology the lock icon in your browser refers to):
|
||||
|
||||
| Scheme | What it is | Privacy |
|
||||
|---|---|---|
|
||||
| `http://` / `ws://` | **Plaintext.** No TLS. | Anyone on the network path can read the traffic. |
|
||||
| `https://` / `wss://` | **TLS-encrypted.** | The traffic is encrypted to the server's certificate. |
|
||||
|
||||
On its own, a plain `http://` or `ws://` link is readable by anyone between your phone
|
||||
and the server — your home router, the coffee-shop Wi-Fi, an upstream ISP. That's why
|
||||
plaintext is only safe on a network you fully trust.
|
||||
|
||||
**But scheme isn't the whole story.** A plain `http://` link can still be fully encrypted
|
||||
if it rides inside an encrypted overlay network like Tailscale. That's the part people
|
||||
get wrong — including, until now, our own docs.
|
||||
|
||||
## What Tailscale actually is
|
||||
|
||||
[Tailscale](https://tailscale.com/) is a **WireGuard-based VPN**. When your phone and your
|
||||
Hermes host are both on your tailnet, every byte between them is **encrypted and
|
||||
authenticated end-to-end by WireGuard** — before it ever touches the URL scheme. This is
|
||||
genuinely secure transport: strong modern encryption plus device identity (only enrolled
|
||||
devices on your tailnet can talk to each other).
|
||||
|
||||
So a connection to `http://100.x.y.z:8642` **over your tailnet is encrypted** — by
|
||||
WireGuard, not by TLS. It is *not* plaintext-on-the-wire even though the scheme says
|
||||
`http`. **Tailscale plaintext is secure; it's just not TLS.** Hermes-Relay treats it as a
|
||||
green/secure route, and never labels a Tailscale route "insecure."
|
||||
|
||||
Tailscale can *also*, separately, terminate TLS for you. Running
|
||||
`tailscale serve --https=<port>` puts a real TLS certificate in front of a service, so the
|
||||
same connection becomes `https://<host>.ts.net:<port>` — now you have **both** WireGuard
|
||||
encryption *and* TLS. You don't need the TLS layer for the link to be secure over a
|
||||
tailnet, but it's there if you want a `wss://`/`https://` route (some tools and proxies
|
||||
expect one).
|
||||
|
||||
::: tip The one thing to remember
|
||||
WireGuard encryption ≠ TLS, but **both are secure transports.** A Tailscale route is
|
||||
encrypted whether or not TLS is also in play. The app's 🛡️ shield means "encrypted by your
|
||||
private network" — it is a green/secure state, not a warning.
|
||||
:::
|
||||
|
||||
## TLS + certificate pinning (TOFU)
|
||||
|
||||
When Hermes-Relay connects over a TLS route (`wss://`/`https://`) for the **first** time,
|
||||
it records a fingerprint of the server's certificate — its SHA‑256 SPKI. This is
|
||||
**trust-on-first-use (TOFU) pinning**: every later connection to that same host must
|
||||
present the *same* certificate, or the app refuses to connect.
|
||||
|
||||
What this buys you:
|
||||
|
||||
- After the first connect, a man-in-the-middle can't swap in a different certificate to
|
||||
intercept your traffic — the pin won't match.
|
||||
- The pin is per `host:port`, stored on-device.
|
||||
- Re-pairing the device (scanning a fresh QR) intentionally resets the pin for that host,
|
||||
because re-pairing is explicit consent to potentially new certificate material.
|
||||
|
||||
Two honest caveats:
|
||||
|
||||
- **Pinning only applies to TLS routes.** A Tailscale-over-`http` route has no TLS
|
||||
certificate to pin — its security comes from WireGuard instead, which provides its own
|
||||
device identity.
|
||||
- **TOFU can't protect the very first connect.** By definition it trusts whatever
|
||||
certificate is present on the initial handshake, so do your first connection over a path
|
||||
you trust (LAN, Tailscale, or VPN). It protects every connection after that.
|
||||
|
||||
## Why one connection has several security states
|
||||
|
||||
A single paired connection isn't one pipe — it fans out to several **surfaces**, and each
|
||||
one can independently be TLS, overlay-encrypted, or plain:
|
||||
|
||||
| Surface | What it carries | Typical port |
|
||||
|---|---|---|
|
||||
| **Chat (gateway)** | Live chat, thinking/reasoning | dashboard `:9119` |
|
||||
| **API / sessions** | Chat fallback, session history | API `:8642` |
|
||||
| **Dashboard (Manage + voice)** | Settings, model config, vanilla voice | dashboard `:9119` |
|
||||
| **Relay tools** | Terminal, bridge, device control | relay `:8767` |
|
||||
|
||||
Because each surface has its own URL, **a connection can be partly encrypted and partly
|
||||
plain at the same time** — for example chat and Manage on `https://`, but relay tools on
|
||||
plain `ws://`. There's no single true/false answer to "is it secure," so a single binary
|
||||
badge would lie.
|
||||
|
||||
Hermes-Relay handles this with a **rollup at a glance, the full truth on tap**:
|
||||
|
||||
- The **glance badge** reflects the worst case across the surfaces actually in use.
|
||||
- **Tapping it** opens a per-surface breakdown so you can see exactly which routes are
|
||||
encrypted and how.
|
||||
|
||||
## How to read the in-app indicator
|
||||
|
||||
The security glyph appears next to the route on the chat status chip and the connection
|
||||
card. There are four outcomes:
|
||||
|
||||
| Indicator | Meaning | Tone |
|
||||
|---|---|---|
|
||||
| 🔒 `Encrypted · TLS` | Every in-use surface is `wss`/`https`, pinned on first connect. | **Secure (green)** |
|
||||
| 🛡️ `Encrypted · Tailscale` | Plain scheme, but the route is Tailscale / WireGuard / a secure proxy — encrypted by the overlay. | **Secure (green)** |
|
||||
| 🛡️ `Mixed routes` | Some surfaces are encrypted, some are plain (a secure fallback exists). | Amber — review the breakdown |
|
||||
| ⚠️ `Not encrypted` | Plain `ws`/`http` with no overlay. | Warning — only safe on a network you fully trust |
|
||||
|
||||
The key idea: **both 🔒 and 🛡️ are green/secure.** Only true plaintext with no overlay is a
|
||||
warning. Tap the indicator for the per-surface detail, where each route is spelled out in
|
||||
one line:
|
||||
|
||||
- *TLS — encrypted to this server's certificate (pinned on first connect).*
|
||||
- *Tailscale — encrypted by your tailnet (WireGuard), not TLS.*
|
||||
- *Not encrypted — only safe on a network you fully trust.*
|
||||
|
||||
If you see ⚠️ **Not encrypted**, you're on a plain `ws://`/`http://` route with nothing
|
||||
wrapping it. That's fine on a home LAN or a trusted VPN, but you should add an encrypted
|
||||
route before using it over public Wi-Fi or the open internet.
|
||||
|
||||
## How to get a TLS (or otherwise encrypted) route
|
||||
|
||||
You have a few ways to make a connection secure. Pick whichever fits your setup:
|
||||
|
||||
### 1. Tailscale (recommended)
|
||||
|
||||
Putting both devices on a tailnet gives you WireGuard encryption immediately — you're
|
||||
secure (🛡️) with no certificates to manage. If you also want TLS-fronted `https://`/`wss://`
|
||||
routes, run Tailscale Serve:
|
||||
|
||||
```bash
|
||||
tailscale serve --https=<port> http://127.0.0.1:<port>
|
||||
```
|
||||
|
||||
The `hermes-relay-tailscale` helper fronts the two relay-owned services for you — relay
|
||||
(`:8767`) and the Hermes API server (`:8642`):
|
||||
|
||||
```bash
|
||||
hermes-relay-tailscale enable
|
||||
```
|
||||
|
||||
The **dashboard** (`:9119`, used for Manage and vanilla voice) is **not** fronted by the
|
||||
helper — if you want a TLS route to the dashboard, you front it yourself with
|
||||
`tailscale serve --https=9119 http://127.0.0.1:9119`. Without that, your dashboard surface
|
||||
rides plain `http` over the tailnet — which is still WireGuard-encrypted and secure, just
|
||||
not TLS.
|
||||
|
||||
### 2. A public reverse proxy
|
||||
|
||||
A proxy like **Caddy**, **nginx**, or **Cloudflare** can terminate TLS in front of your
|
||||
services and expose `https://`/`wss://` routes to the open internet. The proxy holds the
|
||||
certificate; your phone pins it on first connect. This is the path to use when you're
|
||||
exposing Hermes beyond a private network — never expose plain `ws://`/`http://` ports
|
||||
directly.
|
||||
|
||||
### 3. The plugin secure proxy *(not yet available)*
|
||||
|
||||
A future relay-built secure proxy will mint and front its own TLS for the relay surfaces,
|
||||
so you get a pinned `wss://` route without standing up Tailscale Serve or an external
|
||||
proxy. It is **not implemented yet** — when it ships, the app will slot it in
|
||||
automatically as a 🔒 TLS (pinned) route. Until then, use Tailscale or a reverse proxy.
|
||||
|
||||
## See also
|
||||
|
||||
- [Security](./security.md) — full security model: key storage, auth flow, the bridge safety gate.
|
||||
- [Remote access](../guide/remote-access.md) — step-by-step Tailscale and reverse-proxy setup.
|
||||
- [Privacy](./privacy.md) — what data the app stores and where.
|
||||
@@ -103,8 +103,19 @@ Every command is logged to the Bridge tab's activity log (timestamp, status, res
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Use HTTPS** in production — the network security config enforces it by default
|
||||
1. **Use an encrypted route** in production. Two independent ways to get there, both secure:
|
||||
- **TLS** (`https://`/`wss://`) — via a reverse proxy (Caddy/nginx/Cloudflare) or
|
||||
`tailscale serve --https`. Pinned on first connect (TOFU).
|
||||
- **Tailscale / WireGuard** — even a plain `http://`/`ws://` route over your tailnet is
|
||||
**encrypted end-to-end by WireGuard**. This is *not* TLS, but it *is* secure transport;
|
||||
a Tailscale link is not "plaintext on the wire."
|
||||
|
||||
Don't conflate the two: TLS and WireGuard are different mechanisms that both make a
|
||||
connection secure. See [Is my connection secure?](./connection-security.md) for how the
|
||||
app reports each (🔒 TLS vs 🛡️ Tailscale, both green).
|
||||
2. **Rotate API keys** periodically in your Hermes server config
|
||||
3. **Disconnect when idle** — especially if bridge is enabled (or let the auto-disable timer handle it)
|
||||
4. **Avoid public WiFi** for relay connections without additional encryption
|
||||
4. **Avoid plaintext on untrusted networks** — a plain `ws://`/`http://` route with no
|
||||
Tailscale/WireGuard or TLS wrapping it is readable on public Wi-Fi. The app shows
|
||||
⚠️ **Not encrypted** for exactly this case.
|
||||
5. **Keep the app updated** — security patches ship with new releases
|
||||
|
||||
@@ -125,7 +125,7 @@ Beyond the terminal, the tray adds GUI surfaces the headless CLI can't: a **Gran
|
||||
|
||||
They're not the same thing:
|
||||
|
||||
- **`shell`** pipes the host's actual `hermes` CLI through a PTY. You see exactly what `ssh bailey@hermes-host hermes` would show — same banner, same skin, same slash commands. Best for interactive use.
|
||||
- **`shell`** pipes the host's actual `hermes` CLI through a PTY. You see exactly what `ssh you@hermes-host hermes` would show — same banner, same skin, same slash commands. Best for interactive use.
|
||||
- **`chat`** speaks the relay's structured `tui` channel (JSON-RPC-over-WSS), renders events as plain lines. Scriptable, pipeable, survives non-TTY environments. Best for automation / CI / one-shot queries.
|
||||
|
||||
Use `shell` when you want to drive interactively; use `chat --json` from scripts. Chat mode is maintained for automation — it isn't where new features land, and it isn't a desktop chat app (that's [hermes-desktop](https://github.com/NousResearch/hermes-agent)'s job).
|
||||
|
||||
@@ -146,7 +146,7 @@ hermes-relay status --json --reveal-tokens # full tokens (careful — don't pa
|
||||
|
||||
Output per URL:
|
||||
```
|
||||
ws://172.16.24.250:8767
|
||||
ws://192.168.1.100:8767
|
||||
server: 0.6.0
|
||||
paired: 2h ago
|
||||
token: 79d2cf41…8d8c
|
||||
|
||||
@@ -178,7 +178,7 @@ If the agent says "desktop_terminal is not available" or calls time out immediat
|
||||
|
||||
```bash
|
||||
# On the server, verify the channel sees your client
|
||||
ssh bailey@<host> curl -s "http://127.0.0.1:8767/desktop/_ping?tool=desktop_terminal"
|
||||
ssh you@<host> curl -s "http://127.0.0.1:8767/desktop/_ping?tool=desktop_terminal"
|
||||
```
|
||||
|
||||
Expected (the default-advertised set — `desktop_computer_*` appears only when the client runs with `--experimental-computer-use`):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Remote Access
|
||||
|
||||
Hermes-Relay can keep one paired phone connected as it moves between LAN, Tailscale, a VPN, and a public reverse proxy. The recommended path is Tailscale because it works behind CGNAT, gives you managed TLS, and keeps access inside your tailnet ACLs.
|
||||
Hermes-Relay can keep one paired phone connected as it moves between LAN, Tailscale, a VPN, and a public reverse proxy. The recommended path is Tailscale because it works behind CGNAT, encrypts traffic end-to-end (WireGuard), keeps access inside your tailnet ACLs, and can *optionally* front TLS for you. (Note: the WireGuard encryption is what makes a tailnet link secure — TLS via `tailscale serve --https` is a separate, optional layer on top. See [Is my connection secure?](../architecture/connection-security.md).)
|
||||
|
||||
## What Uses Which Connection
|
||||
|
||||
@@ -24,7 +24,7 @@ hermes-relay-tailscale enable
|
||||
hermes pair --mode auto --prefer tailscale
|
||||
```
|
||||
|
||||
The Tailscale helper publishes both required loopback services:
|
||||
The Tailscale helper publishes both required loopback services, fronting each with TLS:
|
||||
|
||||
```bash
|
||||
tailscale serve --bg --https=8767 http://127.0.0.1:8767
|
||||
@@ -33,6 +33,15 @@ tailscale serve --bg --https=8642 http://127.0.0.1:8642
|
||||
|
||||
Port `8767` carries relay WSS and relay HTTP routes. Port `8642` carries the Hermes API server for chat, API-key voice auth, and endpoint health probes. If only `8767` is served, terminal/bridge may work while chat and API-key voice still fail remotely.
|
||||
|
||||
::: tip Two layers, both optional-to-stack
|
||||
Your tailnet is already encrypted by WireGuard, so even a plain `http://100.x.y.z` route is
|
||||
secure over Tailscale. `tailscale serve --https` adds a *separate* TLS layer on top, giving
|
||||
you a `wss://`/`https://` route fronted by a real certificate (the dashboard on `:9119` is
|
||||
not fronted by the helper — front it yourself if you want TLS there). See
|
||||
[Is my connection secure?](../architecture/connection-security.md) for which the app reports
|
||||
as 🔒 TLS vs 🛡️ Tailscale (both secure).
|
||||
:::
|
||||
|
||||
Check the served ports with:
|
||||
|
||||
```bash
|
||||
@@ -65,8 +74,8 @@ You can also override from the phone: **Settings -> Connections -> active connec
|
||||
|
||||
Route fields want the **API server** (port `8642` by default) — never the
|
||||
dashboard (`9119`) or relay (`8767`); those are derived from the host
|
||||
automatically. You can type just a host or IP: `100.71.8.56` is saved as
|
||||
`http://100.71.8.56:8642`, and the editor previews the exact URL before you
|
||||
automatically. You can type just a host or IP: `100.64.0.1` is saved as
|
||||
`http://100.64.0.1:8642`, and the editor previews the exact URL before you
|
||||
save.
|
||||
|
||||
Pick the scheme by how the server is reached:
|
||||
@@ -75,10 +84,14 @@ Pick the scheme by how the server is reached:
|
||||
The Hermes API server speaks plain HTTP; an `https://` route against it
|
||||
fails its TLS handshake on every probe and never wins. This also requires
|
||||
the API server to listen beyond loopback (`0.0.0.0:8642` or the tailnet
|
||||
interface).
|
||||
interface). Note that an `http://` route over a raw Tailscale IP is **not
|
||||
plaintext on the wire** — WireGuard encrypts it end-to-end. It's secure
|
||||
transport, just not TLS (the app reports it as 🛡️ Tailscale, not ⚠️ Not
|
||||
encrypted). A plain LAN IP, by contrast, has no such wrapping.
|
||||
- **`*.ts.net` hostname fronted by `hermes-relay-tailscale enable`** →
|
||||
`https://` — Tailscale terminates TLS for the MagicDNS hostname (the cert
|
||||
is only valid for that name, not for the raw `100.x` IP).
|
||||
is only valid for that name, not for the raw `100.x` IP). This adds TLS
|
||||
*on top of* the WireGuard encryption you already had over the tailnet.
|
||||
- **Public reverse proxy** → `https://` with whatever host/port the proxy
|
||||
exposes.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user