Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77e34c2c02 | ||
|
|
f4ee409106 | ||
|
|
bfb608bea6 | ||
|
|
95a95fe7d2 | ||
|
|
1bdf2ae71b | ||
|
|
f9e7a2f320 | ||
|
|
988fac8522 | ||
|
|
d4832a6a38 | ||
|
|
f9c8736e5b | ||
|
|
a815dd33fa | ||
|
|
cc9c75a636 | ||
|
|
43179e03c0 |
@@ -10,6 +10,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
|
||||
|
||||
## [Android 1.5.2] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Dashboard sign-in completes across supported providers and network routes.** Self-hosted OIDC stays on the dashboard cookie flow, while Nous Portal opens in the system browser and completes standards-compatible PKCE through HTTPS, private-LAN, or Tailscale dashboard routes.
|
||||
- **Replayed chat updates no longer destabilize the conversation list.** Duplicate upstream message identifiers are coalesced before Compose renders them.
|
||||
|
||||
## [Android 1.5.1] - 2026-07-26
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-07-28 — Android 1.5.2 production release
|
||||
|
||||
Android 1.5.2 shipped from the approved `dev` to `main` release tree as
|
||||
versionCode 35. The release adds provider-aware Dashboard sign-in: Nous uses
|
||||
the advertised native PKCE system-browser flow, while compatible self-hosted
|
||||
providers retain cookie-backed full-page Dashboard authentication. Callback
|
||||
origin discovery remains server-driven, private-network HTTP compatibility is
|
||||
preserved, and arbitrary public HTTP redirects remain rejected.
|
||||
|
||||
The private Play preflight validated the exact application tree before release
|
||||
PR #265 merged. The immutable `android-v1.5.2` tag resolves to the resulting
|
||||
`main` tip, the production workflow promoted versionCode 35 to the completed
|
||||
Google Play production track, and the public GitHub release contains the
|
||||
signed AAB, sideload APK, and SHA-256 manifest. The published sideload APK
|
||||
checksum was independently verified; replacing the debug-signed phone build
|
||||
with the release-signed artifact requires an uninstall because Android
|
||||
correctly rejects cross-signature in-place updates.
|
||||
|
||||
## 2026-07-27 — Android replayed-message identity reconciliation
|
||||
|
||||
Android history reconciliation now collapses reconnect/rejoin replays of the
|
||||
same persisted message ID before publishing the transcript to Compose. The
|
||||
latest repeated snapshot replaces the value at the message's first transcript
|
||||
position, preserving stable ordering, distinct messages, and the LazyColumn
|
||||
identity contract without index- or random-key fallbacks.
|
||||
|
||||
Focused coverage reproduces the duplicate UUID condition and verifies that the
|
||||
authoritative final content wins while every rendered message keeps a unique
|
||||
stable UI key.
|
||||
|
||||
## 2026-07-26 — Android 1.5.1 patch reconciliation
|
||||
|
||||
Android 1.5.1 reconciles the post-1.5.0 voice and chat fixes into versionCode
|
||||
|
||||
+10
-15
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay-Android v1.5.1
|
||||
# Hermes-Relay-Android v1.5.2
|
||||
|
||||
**Release Date:** July 26, 2026
|
||||
**Release Date:** July 28, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.5.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.5.2-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,22 +12,17 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch restores reliable narration and background behavior in Voice, adds focused and full-conversation voice layouts, and keeps richly formatted streamed answers anchored at their completed end.
|
||||
|
||||
## Added
|
||||
|
||||
- Voice Focus keeps narration, Markdown, tools, media, and actionable cards in a compact voice-first view.
|
||||
- Voice Conversation exposes the complete Chat renderer while preserving the active voice session.
|
||||
- A final-answer speech preference keeps intermediate progress visual while supported voice paths wait for the settled response.
|
||||
This patch restores reliable dashboard sign-in for self-hosted OIDC and Nous Portal connections, including private-LAN and Tailscale routes, and prevents replayed chat events from destabilizing the conversation list.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Standard Voice narrates valid completed assistant responses instead of losing them during the generation-to-speech handoff.
|
||||
- Realtime tasks promoted to background release the foreground spinner and microphone while progress and results remain reachable.
|
||||
- Completed streamed answers switch to full Markdown and preserve the measured trailing edge instead of jumping to the start of the response.
|
||||
- Assistant text uses the theme's full-contrast foreground with a more readable chat type scale.
|
||||
- Self-hosted OIDC returns through the dashboard cookie flow instead of a desktop-only loopback callback.
|
||||
- Nous Portal authentication opens in the system browser so provider security challenges can complete.
|
||||
- Native PKCE uses standards-compatible unpadded Base64URL and preserves the dashboard's canonical HTTPS callback origin while keeping tokens scoped to the active route.
|
||||
- Full-screen in-app sign-in remains available for compatible dashboard providers.
|
||||
- Replayed upstream chat events are coalesced before rendering, preventing duplicate message keys.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.5.1** (versionCode **34**).
|
||||
- App version: **1.5.2** (versionCode **35**).
|
||||
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Choose compact Voice Focus or the complete Conversation layout. Standard Voice reliably speaks completed replies, Realtime background work no longer blocks voice controls, and streamed answers render Markdown while staying anchored at their completed end.
|
||||
Dashboard sign-in now completes reliably for self-hosted OIDC and Nous Portal, including private-LAN and Tailscale routes. Nous opens securely in the system browser, while compatible providers retain full-screen in-app sign-in. Replayed chat updates no longer duplicate conversation rows.
|
||||
|
||||
@@ -1 +1 @@
|
||||
可选择精简的语音专注视图或完整的对话视图。标准语音现在会可靠朗读已完成的回复,实时后台任务不再阻塞语音控制,流式回复会渲染 Markdown 并停留在完成位置。
|
||||
Hermes 仪表板登录现在可为自托管 OIDC 和 Nous Portal 可靠完成认证,并支持私有局域网与 Tailscale 路由。Nous 会在系统浏览器中安全打开,兼容的提供商仍可使用应用内全屏登录。重放的聊天更新不再产生重复会话行。
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.5.2",
|
||||
"title": "Sign in without detours",
|
||||
"date": "2026-07-28",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Provider-compatible sign-in",
|
||||
"bullets": [
|
||||
"Self-hosted OIDC returns through the dashboard callback, while Nous Portal opens securely in the system browser.",
|
||||
"Private-LAN and Tailscale dashboard routes preserve the configured HTTPS callback and keep credentials scoped to the active connection."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Stable conversation updates",
|
||||
"bullets": [
|
||||
"Replayed upstream chat events are coalesced before rendering so duplicate message identifiers do not destabilize the conversation list."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.5.1",
|
||||
"title": "Voice and chat stay in place",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
v1.5.1 - Voice and chat stay in place
|
||||
v1.5.2 - Sign in without detours
|
||||
|
||||
* Choose a compact Voice Focus view or the complete Conversation renderer.
|
||||
* Hear Standard Voice replies reliably after generation completes.
|
||||
* Keep Realtime background work active without blocking voice controls.
|
||||
* Read formatted streamed answers without jumping back to their beginning.
|
||||
* Complete self-hosted OIDC sign-in through the dashboard callback.
|
||||
* Open Nous Portal securely in the system browser.
|
||||
* Sign in over private-LAN and Tailscale dashboard routes.
|
||||
* Keep replayed chat updates from duplicating conversation rows.
|
||||
|
||||
@@ -247,6 +247,7 @@ data class Connection(
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
extraApiUrls: List<Pair<String, String>> = emptyList(),
|
||||
dashboardUrl: String? = null,
|
||||
): List<EndpointCandidate> {
|
||||
val routes = buildList {
|
||||
endpointCandidateFromApiUrl(
|
||||
@@ -255,6 +256,7 @@ data class Connection(
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
|
||||
dashboardUrl = dashboardUrl,
|
||||
)?.let(::add)
|
||||
|
||||
extraApiUrls
|
||||
@@ -266,6 +268,7 @@ data class Connection(
|
||||
priority = index + 1,
|
||||
apiServerUrl = url,
|
||||
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
|
||||
dashboardUrl = dashboardUrl,
|
||||
)?.let(::add)
|
||||
}
|
||||
}
|
||||
@@ -340,6 +343,7 @@ data class Connection(
|
||||
priority: Int,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
dashboardUrl: String? = null,
|
||||
): EndpointCandidate? {
|
||||
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
|
||||
?: return null
|
||||
@@ -363,12 +367,62 @@ data class Connection(
|
||||
role = role.ifBlank { inferRouteRole(apiServerUrl) },
|
||||
priority = priority,
|
||||
api = ApiEndpoint(host = host, port = port, tls = tls),
|
||||
dashboard = deriveDefaultDashboardUrl(apiServerUrl)
|
||||
dashboard = dashboardUrl
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
?.takeIf { it.isNotBlank() && urlsShareHost(it, apiServerUrl) }
|
||||
?.let { DashboardEndpoint(url = it) }
|
||||
?: deriveDefaultDashboardUrl(apiServerUrl)
|
||||
?.let { DashboardEndpoint(url = it) },
|
||||
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile stored API-derived routes with the Dashboard origin that
|
||||
* was actually verified during setup. Older app versions synthesized
|
||||
* `:9119` for every API route, even when the same host was reached
|
||||
* through an HTTPS reverse proxy on 443. Replace only that conventional
|
||||
* synthesized value (or a missing value); preserve explicit and
|
||||
* different-host LAN/Tailscale routes.
|
||||
*/
|
||||
fun reconcileDashboardRoutes(
|
||||
dashboardUrl: String?,
|
||||
candidates: List<EndpointCandidate>,
|
||||
): List<EndpointCandidate> {
|
||||
val explicitDashboard = dashboardUrl
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return candidates
|
||||
return candidates.map { candidate ->
|
||||
val apiUrl = candidate.api?.url ?: return@map candidate
|
||||
if (!urlsShareHost(explicitDashboard, apiUrl)) return@map candidate
|
||||
|
||||
val currentDashboard = candidate.dashboard?.url
|
||||
val derivedDashboard = deriveDefaultDashboardUrl(apiUrl)
|
||||
val canReplace = currentDashboard.isNullOrBlank() ||
|
||||
(
|
||||
derivedDashboard != null &&
|
||||
currentDashboard.trim().trimEnd('/')
|
||||
.equals(derivedDashboard, ignoreCase = true)
|
||||
)
|
||||
if (canReplace) {
|
||||
candidate.copy(dashboard = DashboardEndpoint(url = explicitDashboard))
|
||||
} else {
|
||||
candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun urlsShareHost(leftUrl: String, rightUrl: String): Boolean {
|
||||
val leftHost = runCatching { URI(leftUrl.trim()) }.getOrNull()?.host
|
||||
val rightHost = runCatching { URI(rightUrl.trim()) }.getOrNull()?.host
|
||||
return !leftHost.isNullOrBlank() &&
|
||||
!rightHost.isNullOrBlank() &&
|
||||
leftHost.equals(rightHost, ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* De-duplication identity for rebuilding stored routes. Prefer the
|
||||
* legacy API authority when present so an older API-only candidate and
|
||||
|
||||
@@ -543,29 +543,6 @@ class ConnectionStore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Connection.withDashboardDefaults(): Connection {
|
||||
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
val normalizedRoutes = routeCandidates.ifEmpty {
|
||||
Connection.buildRouteCandidates(apiServerUrl, relayUrl)
|
||||
}
|
||||
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
|
||||
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
|
||||
}
|
||||
return if (
|
||||
(dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) ||
|
||||
normalizedRoutes != routeCandidates ||
|
||||
normalizedPreferredRouteRole != preferredRouteRole
|
||||
) {
|
||||
copy(
|
||||
dashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl,
|
||||
routeCandidates = normalizedRoutes,
|
||||
preferredRouteRole = normalizedPreferredRouteRole,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConnectionStore"
|
||||
|
||||
@@ -585,3 +562,40 @@ class ConnectionStore private constructor(
|
||||
private const val DEFAULT_RELAY_URL = "ws://localhost:8767"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore route defaults after loading a serialized connection. This remains
|
||||
* internal so focused persistence tests can exercise the same normalization
|
||||
* path used by [ConnectionStore].
|
||||
*/
|
||||
internal fun Connection.withDashboardDefaults(): Connection {
|
||||
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
val effectiveDashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl
|
||||
val storedOrDefaultRoutes = routeCandidates.ifEmpty {
|
||||
Connection.buildRouteCandidates(
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
)
|
||||
}
|
||||
val normalizedRoutes = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
candidates = storedOrDefaultRoutes,
|
||||
)
|
||||
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
|
||||
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
|
||||
}
|
||||
return if (
|
||||
dashboardUrl != effectiveDashboardUrl ||
|
||||
normalizedRoutes != routeCandidates ||
|
||||
normalizedPreferredRouteRole != preferredRouteRole
|
||||
) {
|
||||
copy(
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
routeCandidates = normalizedRoutes,
|
||||
preferredRouteRole = normalizedPreferredRouteRole,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1223,6 +1223,14 @@ class ChatHandler {
|
||||
// so we can attach results back to the originating assistant message's ToolCall
|
||||
val toolResults = items.filter { it.role == "tool" }
|
||||
.associateBy { it.toolCallId }
|
||||
// A reconnect/rejoin history response can repeat a persisted message row.
|
||||
// Chat's LazyColumn renders domain ids as stable keys (via ChatMessage.uiKey),
|
||||
// so allowing both copies through would crash Compose before either copy
|
||||
// could be reconciled. A domain id identifies one persisted message: retain
|
||||
// its first transcript position while adopting the latest repeated snapshot.
|
||||
// Rows without ids remain independent, and tool/hidden rows keep their
|
||||
// separate handling above/below.
|
||||
val renderedItems = coalesceRenderedHistoryItems(items)
|
||||
|
||||
// Accumulator for media markers we find in loaded content — fired AFTER
|
||||
// the wholesale `_messages.value = ...` assignment so the ViewModel's
|
||||
@@ -1240,8 +1248,8 @@ class ChatHandler {
|
||||
// silently misses those rows, so a gateway turn's tokens/badges survived
|
||||
// only if a content match happened to cover them. See
|
||||
// [reconcileLiveIdsToServer].
|
||||
val serverItemIds = items.mapNotNullTo(HashSet()) { it.id }
|
||||
val idRemap = reconcileLiveIdsToServer(items, serverItemIds)
|
||||
val serverItemIds = renderedItems.mapNotNullTo(HashSet()) { it.id }
|
||||
val idRemap = reconcileLiveIdsToServer(renderedItems, serverItemIds)
|
||||
|
||||
// Carry CLIENT-ONLY enrichment forward across the reload, keyed by the
|
||||
// RECONCILED message id. The server transcript (MessageItem) rebuilds
|
||||
@@ -1278,7 +1286,7 @@ class ChatHandler {
|
||||
// clientOnly bubbles (same exchange, pre-sync copy).
|
||||
val syncedRealtimeTurnContents = mutableSetOf<String>()
|
||||
|
||||
val loaded = items.mapNotNull { item ->
|
||||
val loaded = renderedItems.mapNotNull { item ->
|
||||
val displayKind = item.displayKind?.trim()?.lowercase()
|
||||
if (displayKind == "hidden") return@mapNotNull null
|
||||
val role = when {
|
||||
@@ -1540,6 +1548,34 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse replayed visible history rows by their authoritative message id.
|
||||
*
|
||||
* Replacing the value at its first-seen slot preserves transcript ordering;
|
||||
* the last repeated value wins so a later, more complete snapshot is not lost.
|
||||
* Null ids cannot be proven identical and therefore remain separate rows.
|
||||
*/
|
||||
private fun coalesceRenderedHistoryItems(items: List<MessageItem>): List<MessageItem> {
|
||||
val firstSlotById = HashMap<String, Int>()
|
||||
val coalesced = ArrayList<MessageItem>(items.size)
|
||||
for (item in items) {
|
||||
if (renderedRoleOf(item) == null) continue
|
||||
val id = item.id
|
||||
if (id == null) {
|
||||
coalesced += item
|
||||
continue
|
||||
}
|
||||
val existingSlot = firstSlotById[id]
|
||||
if (existingSlot == null) {
|
||||
firstSlotById[id] = coalesced.size
|
||||
coalesced += item
|
||||
} else {
|
||||
coalesced[existingSlot] = item
|
||||
}
|
||||
}
|
||||
return coalesced
|
||||
}
|
||||
|
||||
/** One adoptable server row during id reconciliation. `taken` enforces consume-once. */
|
||||
private class ReconcileSlot(
|
||||
val serverId: String,
|
||||
|
||||
+88
-3
@@ -108,13 +108,18 @@ class NativeDashboardAuthClient(
|
||||
provider: String? = null,
|
||||
): NativeDashboardAuthorization {
|
||||
requireStrictLoopbackRedirect(redirectUri)
|
||||
val verifier = randomBytes(32).base64Url()
|
||||
// RFC 7636 uses unpadded Base64URL. Okio's base64Url() preserves
|
||||
// trailing "=", which makes Hermes' standards-compliant S256
|
||||
// comparison fail even though both sides hashed the same bytes.
|
||||
val verifier = randomBytes(32).base64Url().trimEnd('=')
|
||||
val challenge = MessageDigest.getInstance("SHA-256")
|
||||
.digest(verifier.toByteArray(Charsets.US_ASCII))
|
||||
.toByteString()
|
||||
.base64Url()
|
||||
.trimEnd('=')
|
||||
val state = randomBytes(24).base64Url()
|
||||
val root = "$baseUrl/auth/native/authorize".toHttpUrlOrNull()
|
||||
val authorizationBaseUrl = resolveAuthorizationBaseUrl(provider)
|
||||
val root = "$authorizationBaseUrl/auth/native/authorize".toHttpUrlOrNull()
|
||||
?: throw IOException("Dashboard URL is not a valid http(s) address")
|
||||
val url = root.newBuilder()
|
||||
.addQueryParameter("code_challenge", challenge)
|
||||
@@ -130,6 +135,41 @@ class NativeDashboardAuthClient(
|
||||
return NativeDashboardAuthorization(url, verifier, state, generation)
|
||||
}
|
||||
|
||||
/**
|
||||
* A private-route dashboard may be configured with a canonical HTTPS
|
||||
* callback origin for its provider. Starting the browser on the private
|
||||
* origin would scope Hermes' temporary PKCE cookie to the wrong host, so
|
||||
* discover the provider's declared callback and start native auth there.
|
||||
* Token exchange still uses [baseUrl], keeping the resulting bearer bound
|
||||
* to the active connection route.
|
||||
*/
|
||||
private fun resolveAuthorizationBaseUrl(provider: String?): String {
|
||||
val configured = baseUrl.toHttpUrlOrNull() ?: return baseUrl
|
||||
if (
|
||||
!provider.equals("nous", ignoreCase = true) ||
|
||||
configured.scheme != "http" ||
|
||||
!isPrivateNetworkLiteral(configured.host)
|
||||
) {
|
||||
return baseUrl
|
||||
}
|
||||
val loginUrl = configured.newBuilder()
|
||||
.addPathSegments("auth/login")
|
||||
.addQueryParameter("provider", provider)
|
||||
.addQueryParameter("next", "/")
|
||||
.build()
|
||||
val discoveryClient = client.newBuilder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build()
|
||||
val location = discoveryClient.newCall(
|
||||
Request.Builder().url(loginUrl).get().build(),
|
||||
).execute().use { response ->
|
||||
if (response.code !in 300..399) null else response.header("Location")
|
||||
}
|
||||
return canonicalDashboardBaseFromNousRedirect(location)
|
||||
?: throw IOException("Dashboard did not advertise a secure Nous callback origin")
|
||||
}
|
||||
|
||||
fun exchangeCallback(
|
||||
authorization: NativeDashboardAuthorization,
|
||||
callbackTarget: String,
|
||||
@@ -280,7 +320,52 @@ internal class NativeDashboardCallbackException(
|
||||
internal fun isNativeDashboardTransportEligible(baseUrl: String): Boolean {
|
||||
val url = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
return url.scheme == "https" ||
|
||||
(url.scheme == "http" && url.host == "127.0.0.1")
|
||||
(
|
||||
url.scheme == "http" &&
|
||||
(url.host == "127.0.0.1" || isPrivateNetworkLiteral(url.host))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hermes already permits explicitly configured HTTP dashboard sessions on
|
||||
* local routes. The brokered flow is no less protected than that cookie flow,
|
||||
* but remains unavailable to arbitrary cleartext Internet hosts.
|
||||
*/
|
||||
private fun isPrivateNetworkLiteral(host: String): Boolean {
|
||||
val octets = host.split('.').mapNotNull(String::toIntOrNull)
|
||||
if (octets.size != 4 || octets.any { it !in 0..255 }) return false
|
||||
val first = octets[0]
|
||||
val second = octets[1]
|
||||
return first == 10 ||
|
||||
(first == 172 && second in 16..31) ||
|
||||
(first == 192 && second == 168) ||
|
||||
(first == 100 && second in 64..127)
|
||||
}
|
||||
|
||||
internal fun canonicalDashboardBaseFromNousRedirect(location: String?): String? {
|
||||
val providerUrl = location?.toHttpUrlOrNull() ?: return null
|
||||
if (
|
||||
providerUrl.scheme != "https" ||
|
||||
!providerUrl.host.equals("portal.nousresearch.com", ignoreCase = true)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val callback = providerUrl.queryParameter("redirect_uri")
|
||||
?.toHttpUrlOrNull()
|
||||
?: return null
|
||||
if (callback.scheme != "https") return null
|
||||
val callbackSuffix = "/auth/callback"
|
||||
if (!callback.encodedPath.endsWith(callbackSuffix)) return null
|
||||
val basePath = callback.encodedPath
|
||||
.removeSuffix(callbackSuffix)
|
||||
.ifBlank { "/" }
|
||||
return callback.newBuilder()
|
||||
.encodedPath(basePath)
|
||||
.query(null)
|
||||
.fragment(null)
|
||||
.build()
|
||||
.toString()
|
||||
.trimEnd('/')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
@@ -33,6 +33,24 @@ internal fun dashboardRedirectAuthMode(authFlows: List<String>): DashboardRedire
|
||||
DashboardRedirectAuthMode.WebView
|
||||
}
|
||||
|
||||
/**
|
||||
* Nous Portal uses Cloudflare Turnstile and does not support embedded Android
|
||||
* WebViews. Keep self-hosted OIDC on the dashboard cookie flow, but use the
|
||||
* gateway's brokered system-browser flow for Nous when it is advertised.
|
||||
*/
|
||||
internal fun androidDashboardRedirectAuthMode(
|
||||
providerName: String,
|
||||
authFlows: List<String>,
|
||||
): DashboardRedirectAuthMode =
|
||||
if (
|
||||
providerName.equals("nous", ignoreCase = true) &&
|
||||
dashboardRedirectAuthMode(authFlows) == DashboardRedirectAuthMode.NativePkce
|
||||
) {
|
||||
DashboardRedirectAuthMode.NativePkce
|
||||
} else {
|
||||
DashboardRedirectAuthMode.WebView
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns one native dashboard sign-in attempt.
|
||||
*
|
||||
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -183,8 +184,8 @@ val LocalSnackbarHost = staticCompositionLocalOf<SnackbarHostState> {
|
||||
|
||||
// Short-lived snackbar by default; retryable errors get Long so users have
|
||||
// time to tap the action before it auto-dismisses.
|
||||
suspend fun SnackbarHostState.showHumanError(err: HumanError) {
|
||||
showSnackbar(
|
||||
suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult {
|
||||
return showSnackbar(
|
||||
message = err.body,
|
||||
actionLabel = err.actionLabel,
|
||||
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
|
||||
@@ -1945,6 +1946,16 @@ fun RelayApp() {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onRepairConnection = {
|
||||
navController.navigate(
|
||||
Screen.Pair.route(
|
||||
connectionId = activeConnectionId,
|
||||
autoStart = "relay",
|
||||
),
|
||||
) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
// Empty-chat "needs connection" card also offers the offline
|
||||
// demo, so a skipped / never-connected first run can explore
|
||||
// without leaving Chat. Safe here — this state only shows when
|
||||
|
||||
@@ -139,6 +139,7 @@ import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
@@ -206,6 +207,7 @@ import com.hermesandroid.relay.ui.components.showsImageGenerationPlaceholder
|
||||
import com.hermesandroid.relay.ui.components.VoiceModeOverlay
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.showHumanError
|
||||
import com.hermesandroid.relay.util.HumanErrorAction
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import kotlin.math.abs
|
||||
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||
@@ -466,6 +468,7 @@ fun ChatScreen(
|
||||
// don't wire navigation.
|
||||
onNavigateToConnections: () -> Unit = {},
|
||||
onNavigateToConnect: () -> Unit = onNavigateToConnections,
|
||||
onRepairConnection: () -> Unit = onNavigateToConnect,
|
||||
// Offline demo entry, surfaced on the empty-chat "needs connection" card so a
|
||||
// skipped / never-connected first run can explore without a server. null hides it.
|
||||
onTryDemo: (() -> Unit)? = null,
|
||||
@@ -493,7 +496,13 @@ fun ChatScreen(
|
||||
val snackbarHost = LocalSnackbarHost.current
|
||||
LaunchedEffect(chatViewModel) {
|
||||
chatViewModel.errorEvents.collect { err ->
|
||||
snackbarHost.showHumanError(err)
|
||||
val result = snackbarHost.showHumanError(err)
|
||||
if (
|
||||
result == SnackbarResult.ActionPerformed &&
|
||||
err.action == HumanErrorAction.Repair
|
||||
) {
|
||||
onRepairConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
@@ -21,6 +22,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -43,7 +45,6 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSetupTimeline
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSetupTimelineStep
|
||||
@@ -51,10 +52,10 @@ import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardRedirectAuthMode
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.NativeDashboardSignInCoordinator
|
||||
import com.hermesandroid.relay.network.upstream.dashboardRedirectAuthMode
|
||||
import com.hermesandroid.relay.network.upstream.androidDashboardRedirectAuthMode
|
||||
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
@@ -63,6 +64,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
/**
|
||||
* Connection-level Dashboard authentication flow. It is deliberately outside
|
||||
@@ -92,8 +94,8 @@ fun DashboardSignInScreen(
|
||||
var actionMessage by remember { mutableStateOf<String?>(null) }
|
||||
var actionIsError by remember { mutableStateOf(false) }
|
||||
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
|
||||
var redirectAuthMode by remember(dashboardUrl, connectionId) {
|
||||
mutableStateOf(DashboardRedirectAuthMode.WebView)
|
||||
var authFlows by remember(dashboardUrl, connectionId) {
|
||||
mutableStateOf<List<String>>(emptyList())
|
||||
}
|
||||
var nativeSignInJob by remember(dashboardUrl, connectionId) { mutableStateOf<Job?>(null) }
|
||||
var authenticationComplete by remember { mutableStateOf(false) }
|
||||
@@ -150,7 +152,7 @@ fun DashboardSignInScreen(
|
||||
providers = client.getAuthProviders().getOrNull()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: status.authProviderDetails
|
||||
redirectAuthMode = dashboardRedirectAuthMode(status.authFlows)
|
||||
authFlows = status.authFlows
|
||||
val session = if (status.authRequired) client.currentSession().getOrNull() else null
|
||||
connectionViewModel.recordDashboardStatus(
|
||||
status = status,
|
||||
@@ -196,7 +198,10 @@ fun DashboardSignInScreen(
|
||||
|
||||
fun startRedirectSignIn(provider: DashboardAuthProvider) {
|
||||
if (actionInFlight || dashboardUrl.isBlank()) return
|
||||
if (redirectAuthMode == DashboardRedirectAuthMode.WebView) {
|
||||
if (
|
||||
androidDashboardRedirectAuthMode(provider.name, authFlows) ==
|
||||
DashboardRedirectAuthMode.WebView
|
||||
) {
|
||||
oauthProvider = provider
|
||||
return
|
||||
}
|
||||
@@ -255,10 +260,8 @@ fun DashboardSignInScreen(
|
||||
onDispose { nativeSignInJob?.cancel() }
|
||||
}
|
||||
|
||||
oauthProvider
|
||||
?.takeIf { redirectAuthMode == DashboardRedirectAuthMode.WebView }
|
||||
?.let { provider ->
|
||||
DashboardOAuthDialog(
|
||||
oauthProvider?.let { provider ->
|
||||
DashboardOAuthScreen(
|
||||
dashboardUrl = dashboardUrl,
|
||||
provider = provider,
|
||||
cookieStoreFactory = cookieStoreFactory,
|
||||
@@ -284,6 +287,7 @@ fun DashboardSignInScreen(
|
||||
actionIsError = true
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -291,10 +295,7 @@ fun DashboardSignInScreen(
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.dashboard_sign_in)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
nativeSignInJob?.cancel()
|
||||
onBack()
|
||||
}) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.dashboard_back),
|
||||
@@ -323,9 +324,7 @@ fun DashboardSignInScreen(
|
||||
actionInFlight = actionInFlight,
|
||||
actionMessage = actionMessage,
|
||||
actionIsError = actionIsError,
|
||||
nativePkce = redirectAuthMode == DashboardRedirectAuthMode.NativePkce,
|
||||
nativeSignInInFlight = nativeSignInJob != null,
|
||||
nativeTransportEligible = isNativeDashboardTransportEligible(dashboardUrl),
|
||||
onSignIn = ::submitPassword,
|
||||
onOAuthSignIn = ::startRedirectSignIn,
|
||||
onCancelNativeSignIn = {
|
||||
@@ -398,9 +397,7 @@ private fun DashboardSignInForm(
|
||||
actionInFlight: Boolean,
|
||||
actionMessage: String?,
|
||||
actionIsError: Boolean,
|
||||
nativePkce: Boolean,
|
||||
nativeSignInInFlight: Boolean,
|
||||
nativeTransportEligible: Boolean,
|
||||
onSignIn: (String, String, String) -> Unit,
|
||||
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
|
||||
onCancelNativeSignIn: () -> Unit,
|
||||
@@ -429,18 +426,19 @@ private fun DashboardSignInForm(
|
||||
redirectProviders.forEach { provider ->
|
||||
Button(
|
||||
onClick = { onOAuthSignIn(provider) },
|
||||
enabled = !actionInFlight && (!nativePkce || nativeTransportEligible),
|
||||
enabled = !actionInFlight,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.dashboard_signin_with_provider, provider.displayName ?: provider.name))
|
||||
}
|
||||
}
|
||||
if (nativePkce && !nativeTransportEligible && redirectProviders.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_native_signin_requires_https),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
if (nativeSignInInFlight) {
|
||||
Button(
|
||||
onClick = onCancelNativeSignIn,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.dashboard_cancel))
|
||||
}
|
||||
}
|
||||
if (passwordProvider != null || providers.isEmpty()) {
|
||||
if (redirectProviders.isNotEmpty()) HorizontalDivider()
|
||||
@@ -478,18 +476,11 @@ private fun DashboardSignInForm(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (nativeSignInInFlight) {
|
||||
Button(
|
||||
onClick = onCancelNativeSignIn,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.dashboard_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DashboardOAuthDialog(
|
||||
private fun DashboardOAuthScreen(
|
||||
dashboardUrl: String,
|
||||
provider: DashboardAuthProvider,
|
||||
cookieStoreFactory: () -> DashboardCookieStore,
|
||||
@@ -506,6 +497,8 @@ private fun DashboardOAuthDialog(
|
||||
val verifyFailedStatus = stringResource(R.string.dashboard_oauth_verify_failed)
|
||||
var statusText by remember(initialStatus) { mutableStateOf(initialStatus) }
|
||||
var checking by remember { mutableStateOf(false) }
|
||||
var pageProgress by remember { mutableStateOf(0) }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
val loginUrl = remember(dashboardUrl, provider.name) {
|
||||
DashboardApiClient.authLoginUrl(
|
||||
baseUrl = dashboardUrl,
|
||||
@@ -514,14 +507,17 @@ private fun DashboardOAuthDialog(
|
||||
)
|
||||
}
|
||||
|
||||
fun maybeVerify(url: String?) {
|
||||
fun handleNavigation(url: String?) {
|
||||
val loadedUrl = url?.takeIf { it.isNotBlank() } ?: return
|
||||
val root = dashboardUrl.trim().trimEnd('/')
|
||||
val relative = loadedUrl.trim().removePrefix(root)
|
||||
val stillAuthenticating = relative.startsWith("/login", true) ||
|
||||
relative.startsWith("/auth/login", true) ||
|
||||
relative.startsWith("/auth/callback", true)
|
||||
if (!loadedUrl.startsWith(root, true) || stillAuthenticating) return
|
||||
when (dashboardWebViewAuthNavigation(dashboardUrl, loadedUrl)) {
|
||||
DashboardWebViewAuthNavigation.Continue -> return
|
||||
DashboardWebViewAuthNavigation.RejectLoopbackCallback -> {
|
||||
statusText = notAcceptedStatus
|
||||
onError(notAcceptedStatus)
|
||||
return
|
||||
}
|
||||
DashboardWebViewAuthNavigation.ImportAndVerify -> Unit
|
||||
}
|
||||
val manager = CookieManager.getInstance()
|
||||
manager.flush()
|
||||
val imported = importDashboardCookieHeader(
|
||||
@@ -551,39 +547,162 @@ private fun DashboardOAuthDialog(
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(modifier = Modifier.fillMaxWidth().heightIn(max = 640.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.dashboard_close_signin))
|
||||
}
|
||||
Text(statusText, style = MaterialTheme.typography.bodySmall)
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
factory = { viewContext ->
|
||||
CookieManager.getInstance().setAcceptCookie(true)
|
||||
WebView(viewContext).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean = false
|
||||
BackHandler(onBack = onDismiss)
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
maybeVerify(url)
|
||||
}
|
||||
}
|
||||
loadUrl(loginUrl)
|
||||
}
|
||||
},
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
webView?.stopLoading()
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.dashboard_signin_with_provider,
|
||||
provider.displayName ?: provider.name,
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.dashboard_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
if (pageProgress in 0..99) {
|
||||
LinearProgressIndicator(
|
||||
progress = { pageProgress / 100f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
factory = { viewContext ->
|
||||
CookieManager.getInstance().setAcceptCookie(true)
|
||||
WebView(viewContext).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onProgressChanged(view: WebView, newProgress: Int) {
|
||||
pageProgress = newProgress
|
||||
}
|
||||
}
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean {
|
||||
val target = request.url.toString()
|
||||
if (
|
||||
dashboardWebViewAuthNavigation(dashboardUrl, target) ==
|
||||
DashboardWebViewAuthNavigation.RejectLoopbackCallback
|
||||
) {
|
||||
handleNavigation(target)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceError,
|
||||
) {
|
||||
super.onReceivedError(view, request, error)
|
||||
if (request.isForMainFrame) {
|
||||
val message = error.description?.toString()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: verifyFailedStatus
|
||||
statusText = message
|
||||
onError(message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
handleNavigation(url)
|
||||
}
|
||||
}
|
||||
webView = this
|
||||
loadUrl(loginUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class DashboardWebViewAuthNavigation {
|
||||
Continue,
|
||||
ImportAndVerify,
|
||||
RejectLoopbackCallback,
|
||||
}
|
||||
|
||||
/**
|
||||
* Android redirect providers use the dashboard's cookie/OIDC flow. A foreign
|
||||
* loopback callback belongs to the desktop native-PKCE contract and must never
|
||||
* be followed, imported, or treated as an authenticated Android return.
|
||||
*/
|
||||
internal fun dashboardWebViewAuthNavigation(
|
||||
dashboardUrl: String,
|
||||
loadedUrl: String,
|
||||
): DashboardWebViewAuthNavigation {
|
||||
val dashboard = dashboardUrl.trim().trimEnd('/').toHttpUrlOrNull()
|
||||
?: return DashboardWebViewAuthNavigation.Continue
|
||||
val loaded = loadedUrl.trim().toHttpUrlOrNull()
|
||||
?: return DashboardWebViewAuthNavigation.Continue
|
||||
val sameOrigin = dashboard.scheme == loaded.scheme &&
|
||||
dashboard.host.equals(loaded.host, ignoreCase = true) &&
|
||||
dashboard.port == loaded.port
|
||||
if (!sameOrigin) {
|
||||
val foreignLoopback = loaded.scheme == "http" &&
|
||||
loaded.host in setOf("127.0.0.1", "localhost", "::1") &&
|
||||
loaded.encodedPath == "/callback"
|
||||
return if (foreignLoopback) {
|
||||
DashboardWebViewAuthNavigation.RejectLoopbackCallback
|
||||
} else {
|
||||
DashboardWebViewAuthNavigation.Continue
|
||||
}
|
||||
}
|
||||
|
||||
val basePath = dashboard.encodedPath.trimEnd('/')
|
||||
val relativePath = loaded.encodedPath
|
||||
.removePrefix(basePath)
|
||||
.ifBlank { "/" }
|
||||
return if (
|
||||
relativePath.equals("/login", ignoreCase = true) ||
|
||||
relativePath.equals("/auth/login", ignoreCase = true)
|
||||
) {
|
||||
DashboardWebViewAuthNavigation.Continue
|
||||
} else {
|
||||
// Includes the public /auth/callback response: import its cookies at
|
||||
// root scope, then verify the resulting session through /api/auth/me.
|
||||
DashboardWebViewAuthNavigation.ImportAndVerify
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,11 +22,16 @@ import javax.net.ssl.SSLPeerUnverifiedException
|
||||
* showHumanError in RelayApp.kt.
|
||||
*/
|
||||
|
||||
enum class HumanErrorAction {
|
||||
Repair,
|
||||
}
|
||||
|
||||
data class HumanError(
|
||||
val title: String,
|
||||
val body: String,
|
||||
val retryable: Boolean = false,
|
||||
val actionLabel: String? = null,
|
||||
val action: HumanErrorAction? = null,
|
||||
)
|
||||
|
||||
private fun titlePrefix(context: String?, ctx: Context?): String = ctx?.let { c ->
|
||||
@@ -116,6 +121,7 @@ private fun classifyIoMessage(msg: String, context: String?, ctx: Context?): Hum
|
||||
body = "Your session is no longer valid — re-pair this device",
|
||||
retryable = false,
|
||||
actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair",
|
||||
action = HumanErrorAction.Repair,
|
||||
)
|
||||
"403" in msg || "forbidden" in msg -> HumanError(
|
||||
title = ctx?.getString(R.string.error_classify_not_allowed) ?: "Not allowed",
|
||||
@@ -271,6 +277,7 @@ private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?
|
||||
body = "The server certificate changed since you paired — re-pair to trust it",
|
||||
retryable = false,
|
||||
actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair",
|
||||
action = HumanErrorAction.Repair,
|
||||
)
|
||||
is SecurityException -> HumanError(
|
||||
title = ctx?.getString(R.string.error_classify_perm_needed) ?: "Permission needed",
|
||||
|
||||
@@ -212,9 +212,12 @@ internal fun resolveEffectiveDashboardUrl(
|
||||
endpoint?.dashboard?.url
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { return it }
|
||||
endpoint?.api?.url
|
||||
?.let(Connection::deriveDefaultDashboardUrl)
|
||||
?.let { return it }
|
||||
endpoint?.api?.url?.let { apiUrl ->
|
||||
connection.dashboardUrl
|
||||
?.takeIf { it.isNotBlank() && Connection.urlsShareHost(it, apiUrl) }
|
||||
?.let { return it }
|
||||
Connection.deriveDefaultDashboardUrl(apiUrl)?.let { return it }
|
||||
}
|
||||
return connection.resolvedDashboardUrl
|
||||
}
|
||||
|
||||
@@ -788,6 +791,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
extraApiUrls = extraApiUrls,
|
||||
dashboardUrl = activeConnection.value?.resolvedDashboardUrl,
|
||||
),
|
||||
existing = activeConnection.value?.routeCandidates.orEmpty(),
|
||||
)
|
||||
@@ -4686,17 +4690,21 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
} else {
|
||||
current.dashboardUrl
|
||||
}
|
||||
val newRouteCandidates = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = newDashboardUrl,
|
||||
candidates = payload.endpoints.orEmpty(),
|
||||
)
|
||||
val needsUpdate = current.apiServerUrl != payload.serverUrl ||
|
||||
current.relayUrl != newRelayUrl ||
|
||||
current.dashboardUrl != newDashboardUrl ||
|
||||
current.routeCandidates != payload.endpoints.orEmpty()
|
||||
current.routeCandidates != newRouteCandidates
|
||||
if (needsUpdate) {
|
||||
connectionStore.updateConnection(
|
||||
current.copy(
|
||||
apiServerUrl = payload.serverUrl,
|
||||
relayUrl = newRelayUrl,
|
||||
dashboardUrl = newDashboardUrl,
|
||||
routeCandidates = payload.endpoints.orEmpty(),
|
||||
routeCandidates = newRouteCandidates,
|
||||
preferredRouteRole = current.preferredRouteRole
|
||||
?.takeIf { preferred ->
|
||||
payload.endpoints.orEmpty().any {
|
||||
@@ -4927,6 +4935,22 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
current.copy(
|
||||
label = nextLabel,
|
||||
dashboardUrl = normalized,
|
||||
routeCandidates = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = normalized,
|
||||
candidates = current.routeCandidates.ifEmpty {
|
||||
listOfNotNull(
|
||||
Connection.endpointCandidateFromDashboardUrl(
|
||||
role = Connection.inferRouteRole(normalized),
|
||||
priority = 0,
|
||||
dashboardUrl = normalized,
|
||||
apiServerUrl = current.apiServerUrl
|
||||
.takeIf { it.isNotBlank() },
|
||||
relayUrl = current.relayUrl
|
||||
.takeIf { it.isNotBlank() },
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
probeStandardVoice()
|
||||
@@ -6116,16 +6140,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
) {
|
||||
val activeId = connectionStore.activeConnectionId.value ?: return
|
||||
val current = connectionStore.connections.value.firstOrNull { it.id == activeId } ?: return
|
||||
val nextRouteCandidates = routeCandidates ?: current.routeCandidates
|
||||
val nextPreferredRouteRole = when {
|
||||
preferredRouteRole != null -> preferredRouteRole.takeIf { it.isNotBlank() }
|
||||
routeCandidates != null &&
|
||||
current.preferredRouteRole != null &&
|
||||
nextRouteCandidates.none {
|
||||
it.role.equals(current.preferredRouteRole, ignoreCase = true)
|
||||
} -> null
|
||||
else -> current.preferredRouteRole
|
||||
}
|
||||
val nextDashboardUrl = when {
|
||||
dashboardUrlOverride != null -> {
|
||||
dashboardUrlOverride
|
||||
@@ -6139,6 +6153,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
else -> current.dashboardUrl
|
||||
}
|
||||
val nextRouteCandidates = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = nextDashboardUrl,
|
||||
candidates = routeCandidates ?: current.routeCandidates,
|
||||
)
|
||||
val nextPreferredRouteRole = when {
|
||||
preferredRouteRole != null -> preferredRouteRole.takeIf { it.isNotBlank() }
|
||||
routeCandidates != null &&
|
||||
current.preferredRouteRole != null &&
|
||||
nextRouteCandidates.none {
|
||||
it.role.equals(current.preferredRouteRole, ignoreCase = true)
|
||||
} -> null
|
||||
else -> current.preferredRouteRole
|
||||
}
|
||||
if (
|
||||
current.apiServerUrl == apiServerUrl &&
|
||||
current.relayUrl == relayUrl &&
|
||||
|
||||
@@ -111,6 +111,75 @@ class ConnectionDashboardFieldsTest {
|
||||
assertEquals("wss://hermes.tail1234.ts.net:8767", routes[1].relay?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildRouteCandidates_preservesExplicitSameHostHttpsDashboard() {
|
||||
val routes = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "https://hermes.example.com:8643",
|
||||
relayUrl = "wss://hermes.example.com:8767",
|
||||
dashboardUrl = "https://hermes.example.com:443",
|
||||
)
|
||||
|
||||
assertEquals(1, routes.size)
|
||||
assertEquals("https://hermes.example.com:443", routes.single().dashboard?.url)
|
||||
assertEquals("https://hermes.example.com:8643", routes.single().api?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconcileDashboardRoutes_repairsStoredSameHostDerivedPort() {
|
||||
val stored = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "https://hermes.example.com:8643",
|
||||
relayUrl = "wss://hermes.example.com:8767",
|
||||
)
|
||||
|
||||
val repaired = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = "https://hermes.example.com:443",
|
||||
candidates = stored,
|
||||
)
|
||||
|
||||
assertEquals("https://hermes.example.com:443", repaired.single().dashboard?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconcileDashboardRoutes_keepsDifferentHostRoamingDashboard() {
|
||||
val stored = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "http://100.71.8.56:8642",
|
||||
relayUrl = "ws://100.71.8.56:8767",
|
||||
)
|
||||
|
||||
val repaired = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = "https://hermes.example.com:443",
|
||||
candidates = stored,
|
||||
)
|
||||
|
||||
assertEquals("http://100.71.8.56:9119", repaired.single().dashboard?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistedSecureDashboard_repairsDerivedGatewayRouteOnReload() {
|
||||
val stored = Connection(
|
||||
id = "conn-https",
|
||||
label = "Secure Hermes",
|
||||
apiServerUrl = "https://hermes.example.com:8643",
|
||||
relayUrl = "wss://hermes.example.com:8767",
|
||||
tokenStoreKey = "hermes_auth_https",
|
||||
dashboardUrl = "https://hermes.example.com:443",
|
||||
routeCandidates = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "https://hermes.example.com:8643",
|
||||
relayUrl = "wss://hermes.example.com:8767",
|
||||
),
|
||||
)
|
||||
|
||||
val reloaded = json.decodeFromString<Connection>(
|
||||
json.encodeToString(Connection.serializer(), stored),
|
||||
).withDashboardDefaults()
|
||||
|
||||
assertEquals("https://hermes.example.com:443", reloaded.dashboardUrl)
|
||||
assertEquals(
|
||||
"https://hermes.example.com:443",
|
||||
reloaded.routeCandidates.single().dashboard?.url,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardRouteBuilder_acceptsBareTailscaleHostWithoutOptionalSurfaces() {
|
||||
val route = Connection.endpointCandidateFromDashboardUrl(
|
||||
|
||||
@@ -1517,6 +1517,49 @@ class ChatHandlerTest {
|
||||
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_coalescesReplayedDomainIdWithoutLosingOrderOrContent() {
|
||||
val replayedId = "2c93af28-0b0b-436b-a112-7f164cac931d"
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "user-1",
|
||||
role = "user",
|
||||
content = JsonPrimitive("question"),
|
||||
timestamp = 1.0,
|
||||
),
|
||||
MessageItem(
|
||||
id = replayedId,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("partial answer"),
|
||||
timestamp = 2.0,
|
||||
),
|
||||
MessageItem(
|
||||
id = "system-1",
|
||||
role = "system",
|
||||
content = JsonPrimitive("distinct visible content"),
|
||||
timestamp = 3.0,
|
||||
),
|
||||
// Rejoin replay of the same persisted message. The latest
|
||||
// snapshot is authoritative, but its first transcript position
|
||||
// and Compose identity must remain stable.
|
||||
MessageItem(
|
||||
id = replayedId,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("final answer"),
|
||||
timestamp = 4.0,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val messages = handler.messages.value
|
||||
assertEquals(listOf("user-1", replayedId, "system-1"), messages.map { it.id })
|
||||
assertEquals("final answer", messages[1].content)
|
||||
assertEquals("distinct visible content", messages[2].content)
|
||||
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_secondReloadMatchesByIdAfterReconciliation() {
|
||||
// Once the first reload adopts the server id, subsequent reloads match by
|
||||
|
||||
+28
-1
@@ -68,10 +68,36 @@ class NativeDashboardAuthTest {
|
||||
assertEquals("http://127.0.0.1:43123/callback", query["redirect_uri"])
|
||||
assertEquals("nous", query["provider"])
|
||||
assertTrue(query.getValue("state").length >= 32)
|
||||
assertTrue(query.getValue("code_challenge").length >= 43)
|
||||
assertEquals(43, query.getValue("code_challenge").length)
|
||||
assertFalse(query.getValue("code_challenge").contains('='))
|
||||
assertNotEquals(query["state"], query["code_challenge"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canonicalNousCallbackBase_usesSecurePublicOriginAndPreservesPrefix() {
|
||||
val location = "https://portal.nousresearch.com/oauth/authorize" +
|
||||
"?redirect_uri=https%3A%2F%2Fhermes.example.test%2Fgateway%2Fauth%2Fcallback"
|
||||
|
||||
assertEquals(
|
||||
"https://hermes.example.test/gateway",
|
||||
canonicalDashboardBaseFromNousRedirect(location),
|
||||
)
|
||||
assertEquals(
|
||||
null,
|
||||
canonicalDashboardBaseFromNousRedirect(
|
||||
"https://portal.nousresearch.com/oauth/authorize" +
|
||||
"?redirect_uri=http%3A%2F%2Fhermes.example.test%2Fauth%2Fcallback",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
null,
|
||||
canonicalDashboardBaseFromNousRedirect(
|
||||
"https://attacker.example/oauth/authorize" +
|
||||
"?redirect_uri=https%3A%2F%2Fhermes.example.test%2Fauth%2Fcallback",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun beginAuthorization_rejectsHostnameLoopback() {
|
||||
NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
@@ -103,6 +129,7 @@ class NativeDashboardAuthTest {
|
||||
.digest(verifier.toByteArray(Charsets.US_ASCII))
|
||||
.toByteString()
|
||||
.base64Url()
|
||||
.trimEnd('=')
|
||||
val authorizeChallenge = java.net.URI(authorization.authorizationUrl).rawQuery
|
||||
.split("&")
|
||||
.first { it.startsWith("code_challenge=") }
|
||||
|
||||
+21
@@ -115,7 +115,28 @@ class NativeDashboardSignInCoordinatorTest {
|
||||
)
|
||||
assertTrue(isNativeDashboardTransportEligible("https://hermes.example.test/prefix"))
|
||||
assertTrue(isNativeDashboardTransportEligible("http://127.0.0.1:9119"))
|
||||
assertTrue(isNativeDashboardTransportEligible("http://172.16.24.250:9119"))
|
||||
assertTrue(isNativeDashboardTransportEligible("http://100.71.8.56:9119"))
|
||||
assertFalse(isNativeDashboardTransportEligible("http://hermes.local:9119"))
|
||||
assertFalse(isNativeDashboardTransportEligible("http://203.0.113.10:9119"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun androidRedirectMode_usesBrowserForNous_andCookieFlowForSelfHostedOidc() {
|
||||
val flows = listOf("cookie", "native_pkce")
|
||||
|
||||
assertEquals(
|
||||
DashboardRedirectAuthMode.NativePkce,
|
||||
androidDashboardRedirectAuthMode("nous", flows),
|
||||
)
|
||||
assertEquals(
|
||||
DashboardRedirectAuthMode.WebView,
|
||||
androidDashboardRedirectAuthMode("oidc", flows),
|
||||
)
|
||||
assertEquals(
|
||||
DashboardRedirectAuthMode.WebView,
|
||||
androidDashboardRedirectAuthMode("nous", listOf("cookie")),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun completeSignIn(
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieJar
|
||||
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class DashboardWebViewAuthPolicyTest {
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selfHostedOidc_usesDashboardLoginWithoutNativeOrLoopbackParameters() {
|
||||
val url = DashboardApiClient.authLoginUrl(
|
||||
baseUrl = "https://hermes.example.test",
|
||||
provider = "self-hosted",
|
||||
next = "/",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"https://hermes.example.test/auth/login?provider=self-hosted&next=%2F",
|
||||
url,
|
||||
)
|
||||
assertFalse(url.contains("/auth/native/authorize"))
|
||||
assertFalse(url.contains("redirect_uri"))
|
||||
assertFalse(url.contains("127.0.0.1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publicDashboardCallback_importsCookieAndVerifiesAuthenticatedSession() = runTest {
|
||||
assertEquals(
|
||||
DashboardWebViewAuthNavigation.ImportAndVerify,
|
||||
dashboardWebViewAuthNavigation(
|
||||
"https://hermes.example.test",
|
||||
"https://hermes.example.test/auth/callback?code=public-code&state=public-state",
|
||||
),
|
||||
)
|
||||
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""{"authenticated":true,"username":"operator","provider":"self-hosted"}""",
|
||||
),
|
||||
)
|
||||
val store = InMemoryDashboardCookieStore()
|
||||
val callbackUrl = server.url("/auth/callback?code=public-code").toString()
|
||||
assertEquals(
|
||||
1,
|
||||
importDashboardCookieHeader(
|
||||
store = store,
|
||||
url = callbackUrl,
|
||||
cookieHeader = "hermes_session=authenticated",
|
||||
),
|
||||
)
|
||||
val client = DashboardApiClient(
|
||||
baseUrl = server.url("/").toString(),
|
||||
okHttpClient = OkHttpClient.Builder()
|
||||
.cookieJar(DashboardCookieJar(store))
|
||||
.build(),
|
||||
)
|
||||
|
||||
val session = client.currentSession().getOrThrow()
|
||||
|
||||
assertTrue(session.authenticated)
|
||||
val request = server.takeRequest()
|
||||
assertEquals("/api/auth/me", request.path)
|
||||
assertEquals("hermes_session=authenticated", request.getHeader("Cookie"))
|
||||
client.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foreignLoopbackCallbacksAreRejectedWhileProviderPagesContinue() {
|
||||
val dashboard = "https://hermes.example.test"
|
||||
listOf(
|
||||
"http://127.0.0.1:40179/callback?code=code",
|
||||
"http://localhost:40179/callback?code=code",
|
||||
"http://[::1]:40179/callback?code=code",
|
||||
).forEach { callback ->
|
||||
assertEquals(
|
||||
callback,
|
||||
DashboardWebViewAuthNavigation.RejectLoopbackCallback,
|
||||
dashboardWebViewAuthNavigation(dashboard, callback),
|
||||
)
|
||||
}
|
||||
assertEquals(
|
||||
DashboardWebViewAuthNavigation.Continue,
|
||||
dashboardWebViewAuthNavigation(
|
||||
dashboard,
|
||||
"https://auth.example.test/application/o/authorize/",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -60,6 +61,15 @@ class RelayErrorClassifierTest {
|
||||
|
||||
assertEquals("Session expired", err.title)
|
||||
assertTrue(err.body.contains("re-pair", ignoreCase = true))
|
||||
assertEquals(HumanErrorAction.Repair, err.action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun certificateMismatchExposesRepairAction() {
|
||||
val err = classifyError(SSLException("certificate changed"))
|
||||
|
||||
assertEquals("Certificate mismatch", err.title)
|
||||
assertEquals(HumanErrorAction.Repair, err.action)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+19
-1
@@ -54,7 +54,25 @@ class EffectiveDashboardRouteTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected API-only route derives dashboard even when primary dashboard is explicit`() {
|
||||
fun `selected API-only route keeps explicit same-host secure dashboard`() {
|
||||
val connection = connection(
|
||||
dashboardUrl = "https://hermes.example.com:443",
|
||||
apiServerUrl = "https://hermes.example.com:8643",
|
||||
)
|
||||
val fallback = EndpointCandidate(
|
||||
role = "public",
|
||||
priority = 1,
|
||||
api = ApiEndpoint("hermes.example.com", 8643, tls = true),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"https://hermes.example.com:443",
|
||||
resolveEffectiveDashboardUrl(connection, fallback),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected API-only route derives dashboard for a different route host`() {
|
||||
val connection = connection(
|
||||
dashboardUrl = "http://192.168.1.20:9119",
|
||||
apiServerUrl = "http://192.168.1.20:8642",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.3.0" apply false
|
||||
id("com.android.library") version "9.3.0" apply false
|
||||
id("com.android.application") version "9.3.1" apply false
|
||||
id("com.android.library") version "9.3.1" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" apply false
|
||||
}
|
||||
|
||||
+53
-1
@@ -2169,7 +2169,7 @@ An API endpoint or Relay can be added later without recreating the connection.
|
||||
|
||||
## ADR 39 — Android dashboard redirect auth uses native PKCE
|
||||
|
||||
**Status:** Accepted (2026-07-25).
|
||||
**Status:** Superseded by ADR 40 (2026-07-27).
|
||||
|
||||
**Context.** Android originally completed redirect-provider dashboard sign-in
|
||||
inside a WebView and imported cookies. Current upstream Gateway can advertise a
|
||||
@@ -2202,3 +2202,55 @@ socket.
|
||||
is offered.
|
||||
- Older upstream versions remain usable through the explicitly identified
|
||||
WebView compatibility path.
|
||||
|
||||
---
|
||||
|
||||
## ADR 40 — Android dashboard redirect auth is provider-compatible
|
||||
|
||||
**Status:** Amended (2026-07-28).
|
||||
|
||||
**Context.** Upstream advertises `native_pkce` in `/api/status.auth_flows` for
|
||||
its desktop client. The corresponding `/auth/native/*` broker is explicitly a
|
||||
desktop system-browser flow: it redirects to a loopback listener owned by the
|
||||
desktop process and returns bearer tokens rather than dashboard cookies.
|
||||
Android incorrectly treated that server-wide capability as a platform-neutral
|
||||
mode selector, so redirect providers such as self-hosted OIDC were sent through
|
||||
the desktop loopback contract.
|
||||
|
||||
**Decision.** Android redirect-provider sign-in uses the upstream dashboard
|
||||
cookie flow by default:
|
||||
|
||||
- open `/auth/login?provider=...&next=...` in a full-screen embedded sign-in
|
||||
destination with a normal app bar rather than a modal WebView;
|
||||
- allow the provider to return through the dashboard's public
|
||||
`/auth/callback`;
|
||||
- import only cookies observed on the configured dashboard origin;
|
||||
- verify the imported session through `/api/auth/me`;
|
||||
- reject a foreign `http://127.0.0.1`, `localhost`, or `[::1]` `/callback`
|
||||
navigation instead of following or importing it.
|
||||
|
||||
Android does not select `/auth/native/authorize` merely because it appears in
|
||||
`auth_flows`. Self-hosted OIDC remains on the cookie contract above. Nous Portal
|
||||
is the narrow exception: its Cloudflare Turnstile challenge rejects embedded
|
||||
Android WebViews, so Android uses the gateway-brokered native PKCE route for
|
||||
that provider when advertised and opens it in a system Custom Tab. The
|
||||
ephemeral loopback listener, S256 verifier, state validation, encrypted bearer
|
||||
store, and exact-origin attachment remain app-owned. Public cleartext
|
||||
dashboards are rejected; explicitly configured RFC 1918 and Tailscale-IP
|
||||
dashboard routes retain the same HTTP allowance as their existing cookie
|
||||
sessions. If the provider redirect from a private route declares a canonical
|
||||
HTTPS dashboard callback, Android begins browser authorization on that
|
||||
canonical origin so the temporary PKCE cookie and callback remain same-origin;
|
||||
the one-time code exchange and resulting exact-origin bearer stay bound to the
|
||||
active private route.
|
||||
|
||||
**Consequences.**
|
||||
|
||||
- Self-hosted OIDC uses the same public callback registered for the dashboard.
|
||||
- Android Manage, Chat, Voice, and onboarding continue to share one verified
|
||||
dashboard cookie session.
|
||||
- A server-wide desktop capability can no longer switch Android into a
|
||||
loopback callback flow.
|
||||
- Android retains a full-screen embedded WebView for compatible dashboard
|
||||
cookie providers, while providers that prohibit embedding use the explicit
|
||||
brokered native route.
|
||||
|
||||
@@ -85,12 +85,12 @@ 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.5.1 - Voice and chat stay in place
|
||||
v1.5.2 - Sign in without detours
|
||||
|
||||
* Voice Focus and full Conversation layouts.
|
||||
* Reliable Standard Voice narration after generation.
|
||||
* Realtime background work without blocked voice controls.
|
||||
* Formatted streamed answers stay at their completed end.
|
||||
* Reliable self-hosted OIDC and Nous Portal sign-in.
|
||||
* Secure system-browser flow for Nous provider challenges.
|
||||
* Private-LAN and Tailscale dashboard route support.
|
||||
* Replayed chat updates no longer duplicate rows.
|
||||
```
|
||||
|
||||
## Category
|
||||
|
||||
+15
-10
@@ -170,16 +170,21 @@ Phone control — mirrors upstream relay protocol.
|
||||
|
||||
### 3.3 Auth Flow
|
||||
|
||||
Dashboard/Gateway redirect providers use the upstream native PKCE contract when
|
||||
`GET /api/status` advertises `native_pkce`. Android opens the selected provider
|
||||
in a Custom Tab and owns a single ephemeral callback on
|
||||
`http://127.0.0.1:<os-assigned-port>/callback`. PKCE verifier and CSRF state
|
||||
exist only for that sign-in coroutine. Access and refresh tokens are encrypted
|
||||
per connection and are attached only to the exact trusted dashboard base for
|
||||
Manage, Gateway tickets, and standard voice. Native exchange is allowed only
|
||||
for HTTPS dashboard addresses (plus literal loopback for development). A
|
||||
gateway without the capability uses the legacy cookie/WebView flow; a failed
|
||||
native attempt never silently downgrades.
|
||||
Dashboard/Gateway redirect authentication is provider-compatible. Nous Portal,
|
||||
which relies on a challenge that rejects embedded Android WebViews, uses the
|
||||
upstream brokered `native_pkce` flow in a system Custom Tab when the dashboard
|
||||
advertises it. The app owns an ephemeral loopback callback and stores the
|
||||
resulting bearer session only for that connection and exact dashboard origin.
|
||||
Self-hosted OIDC remains on the dashboard cookie flow: Android opens
|
||||
`/auth/login` in a full-screen embedded browser destination, lets the provider
|
||||
return through the public `/auth/callback`, imports only same-origin cookies,
|
||||
and verifies them through `/api/auth/me`. HTTPS is required on public routes;
|
||||
explicit private-LAN and Tailscale-IP dashboards may use their existing HTTP
|
||||
transport. When such a private route advertises a canonical HTTPS Nous callback,
|
||||
Android starts the browser on that canonical origin so Hermes' temporary PKCE
|
||||
cookie and the provider callback remain same-origin, then exchanges the
|
||||
one-time code through the active private route. The verified session is shared
|
||||
by Manage, Gateway tickets, and standard voice.
|
||||
|
||||
Pairing is QR-driven. The operator runs the pair command on the host — `hermes pair`, `/hermes-relay-pair` from any Hermes chat surface, or the compatibility `hermes-pair` shell shim. All share the same implementation in `plugin/pair.py`. The command probes for a running relay, generates a fresh 6-char code, pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, then embeds the relay URL + code + **chosen TTL + per-channel grants + HMAC signature** (plus the API server credentials and optional dashboard URL) in a single QR payload. The phone scans once, **confirms the TTL and grants via a picker dialog**, and is configured for both chat AND terminal/bridge.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[versions]
|
||||
appVersionName = "1.5.1"
|
||||
appVersionCode = "34"
|
||||
agp = "9.3.0"
|
||||
appVersionName = "1.5.2"
|
||||
appVersionCode = "35"
|
||||
agp = "9.3.1"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.06.01"
|
||||
navigation-compose = "2.9.8"
|
||||
@@ -15,7 +15,7 @@ security-crypto = "1.1.0"
|
||||
tink-android = "1.23.0"
|
||||
lifecycle = "2.11.0"
|
||||
activity-compose = "1.13.0"
|
||||
browser = "1.9.0"
|
||||
browser = "1.10.0"
|
||||
appcompat = "1.7.1"
|
||||
core-ktx = "1.19.0"
|
||||
datastore = "1.2.1"
|
||||
|
||||
@@ -5,8 +5,8 @@ pluginManagement {
|
||||
gradlePluginPortal()
|
||||
}
|
||||
plugins {
|
||||
id("com.android.application") version "9.3.0"
|
||||
id("com.android.library") version "9.3.0"
|
||||
id("com.android.application") version "9.3.1"
|
||||
id("com.android.library") version "9.3.1"
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user