fix(#1213): scope profile-owned REST routes (#1224)

This commit is contained in:
m57 authored and GitHub committed 2026-09-22 09:45:57 +03:00
1 parent d92abd18aa
commit 303019b9a5
2 files changed
+144 -7

No files matched your search

@@ -18,18 +18,16 @@ import okhttp3.Response
* - If no profile is active, pass through unchanged (legacy "default" behavior). * - If no profile is active, pass through unchanged (legacy "default" behavior).
* - If the URL already carries an explicit `profile=` query, leave it * - If the URL already carries an explicit `profile=` query, leave it
* untouched — explicit beats global. * untouched — explicit beats global.
* - Only the endpoint families below honor `?profile=` on the backend; * - Only backend-confirmed profile-aware endpoints are rewritten. Ops is
* everything else (ops, pairing, profiles themselves) is machine-global * intentionally classified route-by-route instead of matching `/api/ops`
* or self-scoped and must NOT be rewritten. Cron, sessions and plugins * wholesale, so future machine-global operations stay untouched.
* are profile-scoped on the backend (`/api/cron/jobs?profile=`,
* `/api/sessions?profile=`, plugin REST) and ARE rewritten here so
* switching profiles actually switches what the app shows.
*/ */
object ProfileScopeInterceptor : Interceptor { object ProfileScopeInterceptor : Interceptor {
private val PROFILE_SCOPED_PREFIXES = private val PROFILE_SCOPED_PREFIXES =
listOf( listOf(
"/api/analytics", "/api/analytics",
"/api/config", "/api/config",
"/api/credentials/pool",
"/api/cron", "/api/cron",
"/api/env", "/api/env",
"/api/gateway", "/api/gateway",
@@ -48,6 +46,28 @@ object ProfileScopeInterceptor : Interceptor {
"/api/skills", "/api/skills",
"/api/status", "/api/status",
"/api/tools/toolsets", "/api/tools/toolsets",
"/api/webhooks",
)
/**
* Mobile-used Ops handlers that accept `?profile=` in Hermes Agent.
*
* Keep this explicit rather than adding `/api/ops`: these routes are
* profile-owned today, while the family itself is not a blanket scoping
* contract. See `hermes_cli/web_routers/ops.py` and `status.py`.
*/
private val PROFILE_SCOPED_OPS_PREFIXES =
listOf(
"/api/ops/backup",
"/api/ops/checkpoints",
"/api/ops/config-migrate",
"/api/ops/debug-share",
"/api/ops/doctor",
"/api/ops/dump",
"/api/ops/hooks",
"/api/ops/import-upload",
"/api/ops/prompt-size",
"/api/ops/security-audit",
) )
/** /**
@@ -55,8 +75,14 @@ object ProfileScopeInterceptor : Interceptor {
* e.g. `/api/status` or `/api/status/health`, but NOT `/api/statusXYZ` * e.g. `/api/status` or `/api/status/health`, but NOT `/api/statusXYZ`
* or `/api/gatewayExtra` (Sourcery review, PR #540). * or `/api/gatewayExtra` (Sourcery review, PR #540).
*/ */
private fun matchesPrefix(
path: String,
prefix: String,
): Boolean = path == prefix || path.startsWith("$prefix/")
private fun isProfileScopedPath(path: String): Boolean = private fun isProfileScopedPath(path: String): Boolean =
PROFILE_SCOPED_PREFIXES.any { prefix -> path == prefix || path.startsWith("$prefix/") } PROFILE_SCOPED_PREFIXES.any { prefix -> matchesPrefix(path, prefix) } ||
PROFILE_SCOPED_OPS_PREFIXES.any { prefix -> matchesPrefix(path, prefix) }
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
@@ -4,8 +4,10 @@ import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -49,6 +51,21 @@ class ProfileScopeInterceptorTest {
return server.takeRequest().requestUrl!! return server.takeRequest().requestUrl!!
} }
private fun requestedUrl(
client: OkHttpClient,
path: String,
method: String = "GET",
): HttpUrl {
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
val builder = Request.Builder().url(server.url(path))
when (method) {
"DELETE" -> builder.delete()
else -> builder.get()
}
client.newCall(builder.build()).execute().close()
return server.takeRequest().requestUrl!!
}
@Test @Test
fun scopedEndpoint_appendsProfile() { fun scopedEndpoint_appendsProfile() {
val client = clientFor("work") val client = clientFor("work")
@@ -131,6 +148,100 @@ class ProfileScopeInterceptorTest {
} }
} }
@Test
fun newlyScopedReads_followActiveProfileAcrossSwitches() {
val client = clientFor("alpha")
assertEquals("alpha", requestedUrl(client, "api/webhooks").queryParameter("profile"))
AuthManager.setActiveProfileId("beta")
assertEquals("beta", requestedUrl(client, "api/credentials/pool").queryParameter("profile"))
AuthManager.setActiveProfileId("alpha")
assertEquals("alpha", requestedUrl(client, "api/ops/hooks").queryParameter("profile"))
}
@Test
fun newlyScopedMutations_followActiveProfileAcrossSwitches() {
val client = clientFor("alpha")
assertEquals(
"alpha",
requestedUrl(client, "api/webhooks/build-events", method = "DELETE").queryParameter("profile"),
)
AuthManager.setActiveProfileId("beta")
assertEquals(
"beta",
requestedUrl(client, "api/credentials/pool/openai/1", method = "DELETE").queryParameter("profile"),
)
AuthManager.setActiveProfileId("alpha")
assertEquals(
"alpha",
requestedUrl(client, "api/ops/hooks", method = "DELETE").queryParameter("profile"),
)
}
@Test
fun mobileUsedOpsRoutes_areScopedWithoutBlanketOpsMatch() {
val client = clientFor("work")
val paths =
listOf(
"api/ops/backup",
"api/ops/backup/download",
"api/ops/doctor",
"api/ops/security-audit",
"api/ops/prompt-size",
"api/ops/dump",
"api/ops/config-migrate",
"api/ops/import-upload",
"api/ops/debug-share",
"api/ops/checkpoints",
"api/ops/checkpoints/prune",
"api/ops/hooks",
)
for (path in paths) {
val url = requestedUrl(client, path)
assertEquals("work", url.queryParameter("profile"))
}
val unclassified = requestedUrl(client, "api/ops/machine-global")
assertNull(unclassified.queryParameter("profile"))
}
@Test
fun destructiveWebhookDelete_avoidsMultiplexUnnamedProfile400() {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
if (request.requestUrl?.queryParameter("profile").isNullOrBlank()) {
MockResponse().setResponseCode(400)
} else {
MockResponse().setResponseCode(200)
}
}
val unscopedClient = clientFor(null)
val unscopedRequest =
Request
.Builder()
.url(server.url("api/webhooks/build-events"))
.delete()
.build()
unscopedClient.newCall(unscopedRequest).execute().use { response ->
assertEquals(400, response.code)
}
val scopedClient = clientFor("beta")
val scopedRequest =
Request
.Builder()
.url(server.url("api/webhooks/build-events"))
.delete()
.build()
scopedClient.newCall(scopedRequest).execute().use { response ->
assertEquals(200, response.code)
}
}
@Test @Test
fun lookalikePath_notScoped() { fun lookalikePath_notScoped() {
// Sourcery review (PR #540): `startsWith` must not match non-segment // Sourcery review (PR #540): `startsWith` must not match non-segment