* fix(network): scope management REST calls with ?profile= (fixes #528) Mobile fired all management REST calls with NO profile scope, so every profile read/wrote the backend's implicit default profile — multi-profile setups silently corrupted config/skills/toolsets/mcp/model/env. Add ProfileScopeInterceptor (OkHttp) that appends ?profile=<activeProfile> to the same endpoint families the desktop/web dashboard scopes (web/src/lib/api.ts PROFILE_SCOPED_PREFIXES), respecting: - no active profile -> pass through (legacy default behavior) - explicit ?profile= on the URL wins over the global scope - only scoped families rewritten; ops/pairing/cron/profiles stay global Wired into ApiClient.buildService(). 5 MockWebServer tests cover scoped append, explicit-param precedence, no-profile passthrough, non-scoped skip, and skills scoping. Mirrors desktop/web contract derived from audit of /opt/hermes-agent. * fix(ci): stub getSelectedProfileId in ApiClientTest + harden prefix match (fixes PR #540) - ApiClientTest: stub AuthManager.getSelectedProfileId() to null. The new ProfileScopeInterceptor (registered in ApiClient.buildService) calls it, and the unstubbed real method threw IllegalStateException at AuthManager.kt:56, breaking all 4 auth-interceptor tests. - ProfileScopeInterceptor: replace startsWith(prefix) with whole-segment match (path == prefix || path.startsWith(prefix/)) so lookalike paths like /api/statusXYZ are NOT wrongly scoped (Sourcery review, PR #540). - ProfileScopeInterceptorTest: add lookalikePath_notScoped + scopedSubPath_isScoped to lock the segment-boundary fix.
This commit is contained in:
4 files changed
+212
No files matched your search
@@ -117,6 +117,7 @@ object ApiClient {
|
||||
.base
|
||||
.newBuilder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(ProfileScopeInterceptor)
|
||||
.addInterceptor(logging)
|
||||
.authenticator(TokenRefreshAuthenticator)
|
||||
.certificatePinner(certificatePinner)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Appends `?profile=<activeProfile>` to profile-scoped management endpoints,
|
||||
* mirroring the desktop/web dashboard contract
|
||||
* (`web/src/lib/api.ts` → `PROFILE_SCOPED_PREFIXES` + `withManagementProfile`).
|
||||
*
|
||||
* Without this, mobile fired all management REST calls with NO profile scope,
|
||||
* so every profile read/wrote the backend's implicit default profile —
|
||||
* multi-profile setups silently corrupted config/skills/toolsets/mcp/model/env
|
||||
* (issue #528).
|
||||
*
|
||||
* Rules (copied from the desktop contract):
|
||||
* - 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, cron, profiles themselves) is
|
||||
* machine-global or self-scoped and must NOT be rewritten.
|
||||
*/
|
||||
object ProfileScopeInterceptor : Interceptor {
|
||||
private val PROFILE_SCOPED_PREFIXES =
|
||||
listOf(
|
||||
"/api/status",
|
||||
"/api/gateway",
|
||||
"/api/analytics",
|
||||
"/api/skills",
|
||||
"/api/tools/toolsets",
|
||||
"/api/config",
|
||||
"/api/env",
|
||||
"/api/mcp",
|
||||
"/api/messaging/platforms",
|
||||
"/api/messaging/telegram/onboarding",
|
||||
"/api/messaging/whatsapp/onboarding",
|
||||
"/api/model/info",
|
||||
"/api/model/set",
|
||||
"/api/model/auxiliary",
|
||||
"/api/model/moa",
|
||||
"/api/model/options",
|
||||
)
|
||||
|
||||
/**
|
||||
* True only when [path] matches a scoped prefix as a whole path segment —
|
||||
* e.g. `/api/status` or `/api/status/health`, but NOT `/api/statusXYZ`
|
||||
* or `/api/gatewayExtra` (Sourcery review, PR #540).
|
||||
*/
|
||||
private fun isProfileScopedPath(path: String): Boolean =
|
||||
PROFILE_SCOPED_PREFIXES.any { prefix -> path == prefix || path.startsWith("$prefix/") }
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val profile =
|
||||
AuthManager.getSelectedProfileId()
|
||||
?: return chain.proceed(request)
|
||||
|
||||
val url = request.url
|
||||
if (url.queryParameter("profile") != null) {
|
||||
return chain.proceed(request) // explicit param wins
|
||||
}
|
||||
|
||||
if (!isProfileScopedPath(url.encodedPath)) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val scopedUrl =
|
||||
url
|
||||
.newBuilder()
|
||||
.addQueryParameter("profile", profile)
|
||||
.build()
|
||||
|
||||
return chain.proceed(request.newBuilder().url(scopedUrl).build())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user