ci(#1374): add gateway contract drift check (#1378)

This commit is contained in:
m57 authored and GitHub committed 2026-09-29 14:37:46 +03:00
1 parent 30afd845e8
commit d07325cd31
8 files changed
+40484

No files matched your search

+1
View File
@@ -311,6 +311,7 @@ com.m57.hermescontrol/
- [README.md](README.md) — human-facing overview, features, screenshots, tech stack
- [DESIGN.md](DESIGN.md) — visual and interaction requirements, accessibility, and token source map
- [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md) — theme template, dispatcher, and mode fallback implementation
- [Gateway contract test](CONTRIBUTING.md#gateway-contract-snapshot) — backend drift check; refresh with `scripts/sync-gateway-contract.sh`
- [CONTRIBUTING.md](CONTRIBUTING.md) — contributor workflow, PR checklist, code style
- [.github/workflows/android.yml](.github/workflows/android.yml) — CI pipeline source of truth
- [.github/workflows/merge-conflict-detector.yml](.github/workflows/merge-conflict-detector.yml) — auto-labels conflicting PRs
+9
View File
@@ -112,6 +112,15 @@ We enforce Kotlin coding conventions and Jetpack Compose best practices.
- Mock time/dispatchers explicitly (`StandardTestDispatcher` + `Dispatchers.setMain`), restore `Dispatchers.Main` in teardown, and cancel test-owned scopes. Follow neighboring tests for the affected component.
- For UI changes, exercise the changed flow on a device or emulator and record the behavior observed. A built or downloaded CI APK alone is not behavioral verification. If device verification is unavailable, state that gate explicitly rather than marking it passed.
### Gateway contract snapshot
`GatewayContractTest` checks `WsMethods`, handled server requests and literal param keys against a vendored copy of
hermes-agent's `gateway-contract.openrpc.json` (`app/src/test/resources/gateway-contract/`, provenance in `SOURCE`).
It is test-only. Refresh it with `scripts/sync-gateway-contract.sh [ref]`, then run
`./gradlew testDebugUnitTest --tests '*GatewayContract*'` and review the diff. Allowlist exceptions need a reason in
`GatewayContractAllowlist`.
---
## Dependency & Build Hygiene
@@ -0,0 +1,93 @@
package com.m57.hermescontrol.data.ws
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* Vendored snapshot of the backend's published gateway contract
* (`apps/shared/src/gateway-contract.openrpc.json` in hermes-agent), test-only.
* Refresh with `scripts/sync-gateway-contract.sh`; provenance is in `SOURCE`.
*/
object GatewayContract {
private val root: JsonObject by lazy {
val stream =
requireNotNull(GatewayContract::class.java.getResourceAsStream("/gateway-contract/openrpc.json")) {
"gateway-contract/openrpc.json missing from test resources"
}
Json.parseToJsonElement(stream.bufferedReader().use { it.readText() }).jsonObject
}
private val schemas: JsonObject get() = root["components"]!!.jsonObject["schemas"]!!.jsonObject
private fun names(key: String): Set<String> =
(root[key] as JsonArray).map { it.jsonObject["name"]!!.jsonPrimitive.content }.toSet()
val methods: Set<String> by lazy { names("methods") }
/** Server→client request methods (`x-server-requests`). */
val serverRequests: Set<String> by lazy { names("x-server-requests") }
private fun paramsSchema(method: String): JsonObject {
val entry =
(root["methods"] as JsonArray)
.map { it.jsonObject }
.first { it["name"]!!.jsonPrimitive.content == method }
val ref =
entry["params"]!!
.jsonArray
.first()
.jsonObject["schema"]!!
.jsonObject["\$ref"]!!
.jsonPrimitive.content
return schemas[ref.substringAfterLast('/')]!!.jsonObject
}
/**
* Keys the backend would reject for [method]: unknown keys when the params
* schema forbids extras, plus required keys that are absent. Empty means OK.
* Keys only; value types are not checked.
*/
fun paramKeyProblems(
method: String,
keys: Set<String>,
): List<String> {
if (method !in methods) return listOf("$method is not a contract method")
val schema = paramsSchema(method)
val properties = schema["properties"]?.jsonObject?.keys.orEmpty()
val required = schema["required"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty()
val strict = schema["additionalProperties"]?.jsonPrimitive?.boolean == false
val problems = mutableListOf<String>()
if (strict) {
(keys - properties).sorted().forEach {
problems += "$method: unknown param '$it' (backend forbids extra keys; allowed: ${properties.sorted()})"
}
}
required.filter { it !in keys }.forEach { problems += "$method: missing required param '$it'" }
return problems
}
}
/**
* WsMethods constants the backend contract does not list. Every entry needs a
* reason; the test fails if an entry appears in the contract or is no longer a
* WsMethods constant, so stale exceptions get removed.
*/
object GatewayContractAllowlist {
private const val LEGACY = "legacy fallback for gateways before srq request frames (#1119)"
val entries: Map<String, String> =
mapOf(
"gateway.ping" to "handled inline by the WS transport itself (tui_gateway/ws.py), not a contract method",
"clarify.respond" to LEGACY,
"sudo.respond" to LEGACY,
"secret.respond" to LEGACY,
"vault.unlock.respond" to LEGACY,
"vault.code.respond" to LEGACY,
"vault.save_login.respond" to LEGACY,
)
}
@@ -0,0 +1,146 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* Sends the client-owned RPCs through the real [HermesWsClient] and checks the
* exact frame on the wire against the vendored gateway contract (#1374).
*/
class GatewayContractFramesTest {
private lateinit var mockWebServer: MockWebServer
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.e(any<String>(), any<String>(), any()) } returns 0
mockWebServer = MockWebServer()
mockWebServer.start()
mockkObject(AuthManager)
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config
.ServerStoreState()
}
CookieManager.setJarForTest(buildFakePersistentCookieJar())
mockkObject(DashboardSessionTokenRefresher)
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
every { DashboardSessionTokenRefresher.refresh() } returns null
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.setAppForeground(true)
}
@After
fun tearDown() {
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
/** Connect, run [trigger], and return the first frame the server sees for [method]. */
private fun captureFrame(
method: String,
beforeConnect: () -> Unit = {},
trigger: () -> Unit = {},
): JsonObject {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frame = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (frame.get() == null && name == method) {
frame.set(json)
captured.countDown()
}
}
},
),
)
beforeConnect()
HermesWsClient.connect()
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
}
assertTrue(opened.await(5, TimeUnit.SECONDS))
trigger()
assertTrue("No '$method' frame reached the server", captured.await(5, TimeUnit.SECONDS))
return requireNotNull(frame.get())
}
private fun assertMatchesContract(frame: JsonObject) {
val method = (frame["method"] as JsonPrimitive).content
val params = frame["params"] as JsonObject
val problems = GatewayContract.paramKeyProblems(method, params.keys)
assertTrue(problems.joinToString("\n"), problems.isEmpty())
}
@Test
fun promptSubmitFrameMatchesContract() {
assertMatchesContract(
captureFrame(WsMethods.PROMPT_SUBMIT) { HermesWsClient.sendMessage("s1", "hello", queued = true) },
)
}
@Test
fun eventsSinceReplayFrameMatchesContract() {
assertMatchesContract(
captureFrame(WsMethods.SESSION_EVENTS_SINCE, beforeConnect = { HermesWsClient.setSeqWatermark("s1", 5) }),
)
}
}
@@ -0,0 +1,98 @@
package com.m57.hermescontrol.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.lang.reflect.Modifier
/** Fails CI when the app drifts from the backend's published gateway contract (#1374). */
class GatewayContractTest {
private val wsMethodConstants: Map<String, String> =
WsMethods::class.java.declaredFields
.filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java }
.associate { it.name to it.get(null) as String }
@Test
fun everyWsMethodIsInTheContractOrAllowlisted() {
val unknown =
wsMethodConstants.filter { (_, method) ->
method !in GatewayContract.methods && method !in GatewayContractAllowlist.entries
}
assertTrue(
"WsMethods constants missing from the gateway contract (backend removed or renamed them?): " +
unknown.entries.joinToString { "${it.key}=\"${it.value}\"" } +
". Fix the constant, or allowlist it with a reason in GatewayContractAllowlist.",
unknown.isEmpty(),
)
}
@Test
fun profileScopedMethodsAreContractMethods() {
val unknown = WsMethods.PROFILE_SCOPED_METHODS - GatewayContract.methods
assertTrue("PROFILE_SCOPED_METHODS not in the contract: $unknown", unknown.isEmpty())
}
@Test
fun allowlistEntriesHaveReasonsAndAreNotStale() {
val sent = wsMethodConstants.values.toSet()
GatewayContractAllowlist.entries.forEach { (method, reason) ->
assertTrue("Allowlist entry '$method' needs a reason", reason.isNotBlank())
assertTrue(
"Allowlist entry '$method' is now in the contract; remove it from GatewayContractAllowlist",
method !in GatewayContract.methods,
)
assertTrue(
"Allowlist entry '$method' is no longer a WsMethods constant; remove it",
method in sent,
)
}
}
@Test
fun handledServerRequestsExistInTheContract() {
val unknown = APP_HANDLED_SERVER_REQUESTS - GatewayContract.serverRequests
assertTrue("Handled server requests missing from the contract: $unknown", unknown.isEmpty())
// Informational: unsupported requests are already answered with -32601.
val unhandled = (GatewayContract.serverRequests - APP_HANDLED_SERVER_REQUESTS).sorted()
println("Contract server requests the app does not handle: $unhandled")
}
@Test
fun paramKeyCheckRejectsTheRegressionsItExistsFor() {
// #1118: the backend forbids extra keys, so since_seq broke replay with error 4000.
val problems = GatewayContract.paramKeyProblems("session.events.since", setOf("session_id", "since_seq"))
assertEquals(1, problems.count { "since_seq" in it })
assertTrue(GatewayContract.paramKeyProblems("session.events.since", setOf("session_id")).isEmpty())
assertTrue(GatewayContract.paramKeyProblems("session.nope", emptySet()).isNotEmpty())
}
@Test
fun literalParamKeysAtCallSitesMatchTheContract() {
CALL_SITE_PARAM_KEYS.forEach { (method, keys) ->
val problems = GatewayContract.paramKeyProblems(method, keys)
assertTrue(problems.joinToString("\n"), problems.isEmpty())
}
}
private companion object {
/** Methods ChatViewModel.handleServerRequest routes; keep in sync with its `when`. */
val APP_HANDLED_SERVER_REQUESTS =
setOf("approval", "clarify", "sudo", "secret", "vault.code", "vault.save_login", "vault.unlock_prompt")
/**
* Union of literal param keys at production call sites (the two client-owned
* ones, prompt.submit and session.events.since, are checked against real frames
* in [GatewayContractFramesTest]). Update together with the call site.
*/
val CALL_SITE_PARAM_KEYS: Map<String, Set<String>> =
mapOf(
// ChatViewModel, GroupChatViewModel
"session.create" to setOf("profile", "title", "source", "hidden"),
// ChatViewModel.resumeSession, NotificationReplyReceiver
"session.resume" to setOf("session_id", "omit_messages"),
// ChatViewModel, GroupChatViewModel
"session.interrupt" to setOf("session_id"),
)
}
}
@@ -0,0 +1,5 @@
repo: NousResearch/hermes-agent
ref: main
sha: ea114c3e98c3339e13004adfc6098cf28ed7d754
date: 2026-09-29
path: apps/shared/src/gateway-contract.openrpc.json
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Refresh the vendored gateway contract snapshot from hermes-agent (#1374).
# Usage: scripts/sync-gateway-contract.sh [ref] (default: main)
set -euo pipefail
REF="${1:-main}"
REPO="NousResearch/hermes-agent"
CONTRACT_PATH="apps/shared/src/gateway-contract.openrpc.json"
DEST="$(cd "$(dirname "$0")/.." && pwd)/app/src/test/resources/gateway-contract"
SHA="$(gh api "repos/$REPO/commits/$REF" --jq .sha)"
DATE="$(gh api "repos/$REPO/commits/$SHA" --jq .commit.committer.date)"
mkdir -p "$DEST"
gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$CONTRACT_PATH?ref=$SHA" > "$DEST/openrpc.json"
jq -e '.methods | length > 0' "$DEST/openrpc.json" > /dev/null
printf 'repo: %s\nref: %s\nsha: %s\ndate: %s\npath: %s\n' "$REPO" "$REF" "$SHA" "${DATE%%T*}" "$CONTRACT_PATH" > "$DEST/SOURCE"
echo "Vendored $REPO@$SHA. Run ./gradlew testDebugUnitTest --tests '*GatewayContract*' and review the diff."