diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt index 72de4781..497a5a63 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt @@ -18,18 +18,16 @@ import okhttp3.Response * - If no profile is active, pass through unchanged (legacy "default" behavior). * - If the URL already carries an explicit `profile=` query, leave it * untouched — explicit beats global. - * - Only the endpoint families below honor `?profile=` on the backend; - * everything else (ops, pairing, profiles themselves) is machine-global - * or self-scoped and must NOT be rewritten. Cron, sessions and plugins - * 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. + * - Only backend-confirmed profile-aware endpoints are rewritten. Ops is + * intentionally classified route-by-route instead of matching `/api/ops` + * wholesale, so future machine-global operations stay untouched. */ object ProfileScopeInterceptor : Interceptor { private val PROFILE_SCOPED_PREFIXES = listOf( "/api/analytics", "/api/config", + "/api/credentials/pool", "/api/cron", "/api/env", "/api/gateway", @@ -48,6 +46,28 @@ object ProfileScopeInterceptor : Interceptor { "/api/skills", "/api/status", "/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` * 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 = - 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 { val request = chain.request() diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt index c97cee18..97a5a730 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt @@ -4,8 +4,10 @@ import com.m57.hermescontrol.data.local.AuthManager import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -49,6 +51,21 @@ class ProfileScopeInterceptorTest { 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 fun scopedEndpoint_appendsProfile() { 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 fun lookalikePath_notScoped() { // Sourcery review (PR #540): `startsWith` must not match non-segment