fix(#700): omit Bearer header on gated-mode REST calls (#701)

In gated mode (basic-auth cookie flow) the dashboard 401s any request that
carries an Authorization: Bearer header alongside the valid session cookie
(verified live 2026-07-23). ApiClient.authInterceptor stamped the WS ticket
as a Bearer header on every REST call, so every non-chat tab (skills, cron,
config, tools, mcp, ...) failed with "token expired" while the WS chat
(?ticket=) kept working.

- Add AuthManager.isGatedMode() (wsAuthParam == "ticket").
- authInterceptor skips the Bearer header in gated mode; loopback/token
  behavior unchanged.
- TokenRefreshAuthenticator bails in gated mode instead of attempting the
  loopback-only SPA token refresh.
- ApiClientTest: add gated/loopback regression tests. 7/7 pass, ktlint clean.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-23 14:46:36 +03:00
1 parent b2a7dabbfc
commit 152b7d5c0d
4 files changed
+85 -6

No files matched your search

@@ -49,6 +49,11 @@ class ApiClientTest {
// which calls getSelectedProfileId(). Stub it to null so the interceptor
// short-circuits (no profile scope) and these auth tests stay focused.
every { AuthManager.getSelectedProfileId() } returns null
// Default to loopback/token mode; the gated-mode test overrides this.
// (mockkObject runs the real isGatedMode() body, which reads serverStore
// — null in unit tests — so it must be stubbed.)
every { AuthManager.isGatedMode() } returns false
}
@AfterEach
@@ -140,6 +145,50 @@ class ApiClientTest {
assertEquals("Bearer $rawToken", request.getHeader("Authorization"))
}
@Test
fun testAuthInterceptor_omitsBearerInGatedModeEvenWithToken() =
runTest {
// Gated (basic-auth cookie) mode: wsAuthParam == "ticket". The mobile
// still stores the WS ticket as the token, but stamping it as a Bearer
// header 401s on the live dashboard (verified 2026-07-23) — the session
// cookie in the shared jar is the only valid REST credential.
every { AuthManager.getToken() } returns "ws-ticket-abc123"
every { AuthManager.isGatedMode() } returns true
ApiClient.rebuild()
mockWebServer.enqueue(
MockResponse().setResponseCode(200).setBody("""{"gateway_running":true}"""),
)
val response = ApiClient.hermesApi.getStatus()
assertTrue(response.isSuccessful)
val request = mockWebServer.takeRequest()
// No Bearer header must be sent in gated mode.
assertNull(request.getHeader("Authorization"))
}
@Test
fun testAuthInterceptor_stampsBearerInLoopbackMode() =
runTest {
// Loopback token mode: wsAuthParam != "ticket" → Bearer header required.
every { AuthManager.getToken() } returns "loopback-token-xyz"
every { AuthManager.isGatedMode() } returns false
ApiClient.rebuild()
mockWebServer.enqueue(
MockResponse().setResponseCode(200).setBody("""{"gateway_running":true}"""),
)
val response = ApiClient.hermesApi.getStatus()
assertTrue(response.isSuccessful)
val request = mockWebServer.takeRequest()
assertEquals("Bearer loopback-token-xyz", request.getHeader("Authorization"))
}
@Test
fun testAuthInterceptor_refreshesExpiredDashboardTokenAndRetries() =
runTest {