diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridge.kt index dea02a84df..47679dbcf1 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridge.kt @@ -1,17 +1,45 @@ package ai.rever.boss.kernel.services +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor import ai.rever.boss.ipc.proto.Empty import ai.rever.boss.ipc.proto.services.* import ai.rever.boss.plugin.api.ActiveTabsProvider +import ai.rever.boss.plugin.logging.BossLogger +import ai.rever.boss.plugin.logging.LogCategory +import io.grpc.Status +import io.grpc.StatusException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +/** + * Kernel-side bridge for `ActiveTabsService`. + * + * Revocation is checked before each emission; idle streams are not proactively disconnected. + * + * **Every call requires a verified caller identity (BossConsole#53)**, the same requirement and + * helper shape introduced for the Secret Service in PR #505. Before this bridge checked identity + * at all, any process able to open a connection to the kernel IPC server - not only the plugins + * the host itself loaded - could [watchActiveTabs] to see every open tab's title and URL across + * every window (browsing history and open documents, disclosed with no credential), and could + * [createBrowserTab] to force the browser to navigate to an arbitrary URL, or [closeTab]/ + * [selectTab] to manipulate what the user is looking at. + */ +// One method per RPC the generated service base class declares, plus small identity and audit helpers. +@Suppress("TooManyFunctions") class ActiveTabsServiceBridge( private val provider: ActiveTabsProvider, ) : ActiveTabsServiceGrpcKt.ActiveTabsServiceCoroutineImplBase() { - override fun watchActiveTabs(request: Empty): Flow = - flow { + override fun watchActiveTabs(request: Empty): Flow { + // Capture the gRPC context before returning the asynchronously collected flow. + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchActiveTabs") provider.activeTabs.collect { tabs -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchActiveTabs", "revocation") + } emit( ActiveTabListResponse .newBuilder() @@ -35,18 +63,23 @@ class ActiveTabsServiceBridge( ) } } + } override suspend fun refreshTabs(request: Empty): Empty { + authenticatedCallerOrRefuse("refreshTabs") provider.refreshTabs() return Empty.getDefaultInstance() } override suspend fun selectTab(request: SelectTabRequest): Empty { + val caller = authenticatedCallerOrRefuse("selectTab") + logMutation("selectTab", caller) provider.selectTab(request.tabId, request.panelId) return Empty.getDefaultInstance() } override suspend fun getTabUrl(request: TabIdRequest): ActiveTabStringResponse { + authenticatedCallerOrRefuse("getTabUrl") val url = provider.getTabUrl(request.tabId) return ActiveTabStringResponse .newBuilder() @@ -55,6 +88,7 @@ class ActiveTabsServiceBridge( } override suspend fun getFaviconCacheKey(request: TabIdRequest): ActiveTabStringResponse { + authenticatedCallerOrRefuse("getFaviconCacheKey") val key = provider.getFaviconCacheKey(request.tabId) return ActiveTabStringResponse .newBuilder() @@ -63,6 +97,8 @@ class ActiveTabsServiceBridge( } override suspend fun createBrowserTab(request: CreateBrowserTabRequest): ActiveTabStringResponse { + val caller = authenticatedCallerOrRefuse("createBrowserTab") + logMutation("createBrowserTab", caller) val tabId = provider.createBrowserTab(request.url, request.title) return ActiveTabStringResponse .newBuilder() @@ -71,10 +107,46 @@ class ActiveTabsServiceBridge( } override suspend fun closeTab(request: TabIdRequest): ActiveTabBoolResponse { + val caller = authenticatedCallerOrRefuse("closeTab") + logMutation("closeTab", caller) val success = provider.closeTab(request.tabId) return ActiveTabBoolResponse .newBuilder() .setValue(success) .build() } + + /** + * Unary RPCs only: the verified identity, or a thrown `PERMISSION_DENIED` when there is none. + * + * Mirrors the helper introduced by PR #505 (BossConsole#53) - fails closed rather than let a + * request with no credential fall through to [provider] with nothing to attribute it to. + */ + private fun authenticatedCallerOrRefuse(rpc: String): String = + ProcessIdentityInterceptor.AUTHENTICATED_PROCESS_ID.get() ?: refuseIdentity(rpc) + + private fun logMutation( + rpc: String, + caller: String, + ) { + logger.info(LogCategory.AUTH, "Authenticated tab mutation requested", mapOf("rpc" to rpc, "caller" to caller)) + } + + private fun refuseIdentity( + rpc: String, + stage: String = "bind", + ): Nothing { + logger.warn( + LogCategory.AUTH, + "Refused $rpc: no current verified process identity on this call", + mapOf("rpc" to rpc, "stage" to stage), + ) + throw StatusException(Status.PERMISSION_DENIED.withDescription(NO_IDENTITY)) + } + + private companion object { + val logger = BossLogger.forComponent("ActiveTabsServiceBridge") + + const val NO_IDENTITY = "This call presented no verified process identity" + } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridgeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridgeTest.kt new file mode 100644 index 0000000000..a9fe582f9a --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridgeTest.kt @@ -0,0 +1,257 @@ +package ai.rever.boss.kernel.services + +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenClientInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenRegistry +import ai.rever.boss.ipc.proto.Empty +import ai.rever.boss.ipc.proto.services.ActiveTabsServiceGrpcKt +import ai.rever.boss.ipc.proto.services.CreateBrowserTabRequest +import ai.rever.boss.ipc.proto.services.SelectTabRequest +import ai.rever.boss.ipc.proto.services.TabIdRequest +import ai.rever.boss.plugin.api.ActiveTabData +import ai.rever.boss.plugin.api.ActiveTabsProvider +import ai.rever.boss.plugin.api.BrowserIntegration +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.Server +import io.grpc.ServerBuilder +import io.grpc.Status +import io.grpc.StatusException +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withTimeout +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The active-tabs bridge, exercised over a real gRPC server (BossConsole#53). + * + * Before this bridge checked identity, any process able to open a connection to the kernel IPC + * server - not only the plugins the host itself loaded - could watch every open tab's title and + * URL across every window with no credential at all, and could force the browser to navigate to + * an arbitrary URL via `createBrowserTab`. + */ +class ActiveTabsServiceBridgeTest { + private lateinit var tokenRegistry: ProcessTokenRegistry + private lateinit var provider: FakeActiveTabsProvider + private lateinit var server: Server + private lateinit var authenticatedChannel: ManagedChannel + private lateinit var anonymousChannel: ManagedChannel + private lateinit var authenticated: ActiveTabsServiceGrpcKt.ActiveTabsServiceCoroutineStub + private lateinit var anonymous: ActiveTabsServiceGrpcKt.ActiveTabsServiceCoroutineStub + + @BeforeTest + fun setUp() { + tokenRegistry = ProcessTokenRegistry() + provider = FakeActiveTabsProvider() + server = + ServerBuilder + .forPort(0) + .intercept(ProcessIdentityInterceptor(tokenRegistry)) + .addService(ActiveTabsServiceBridge(provider)) + .build() + .start() + authenticatedChannel = + ManagedChannelBuilder + .forAddress("localhost", server.port) + .usePlaintext() + .intercept(ProcessTokenClientInterceptor(tokenRegistry.issue(CALLER))) + .build() + anonymousChannel = ManagedChannelBuilder.forAddress("localhost", server.port).usePlaintext().build() + authenticated = ActiveTabsServiceGrpcKt.ActiveTabsServiceCoroutineStub(authenticatedChannel) + anonymous = ActiveTabsServiceGrpcKt.ActiveTabsServiceCoroutineStub(anonymousChannel) + } + + @AfterTest + fun tearDown() { + authenticatedChannel.shutdownNow() + authenticatedChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + anonymousChannel.shutdownNow() + anonymousChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + server.shutdownNow() + server.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + + @Test + fun `every RPC is refused with no credential, and never reaches the tab provider`() = + runBlocking { + assertRefused { anonymous.watchActiveTabs(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.refreshTabs(Empty.getDefaultInstance()) } + assertRefused { anonymous.selectTab(SelectTabRequest.newBuilder().setTabId("t1").build()) } + assertRefused { anonymous.getTabUrl(TabIdRequest.newBuilder().setTabId("t1").build()) } + assertRefused { anonymous.getFaviconCacheKey(TabIdRequest.newBuilder().setTabId("t1").build()) } + assertRefused { + anonymous.createBrowserTab(CreateBrowserTabRequest.newBuilder().setUrl("https://evil.example").build()) + } + assertRefused { anonymous.closeTab(TabIdRequest.newBuilder().setTabId("t1").build()) } + + assertTrue(provider.calls.isEmpty(), "a refused call must never reach the tab provider") + } + + @Test + fun `an authenticated caller can watch and read tabs`() = + runBlocking { + provider.setTabs(listOf(tab("t1", url = "https://example.com"))) + + val response = + authenticated + .watchActiveTabs(Empty.getDefaultInstance()) + .take(1) + .toList() + .single() + assertEquals("https://example.com", response.tabsList.single().url) + + val url = authenticated.getTabUrl(TabIdRequest.newBuilder().setTabId("t1").build()) + assertEquals("https://example.com", url.value) + authenticated.refreshTabs(Empty.getDefaultInstance()) + val favicon = authenticated.getFaviconCacheKey(TabIdRequest.newBuilder().setTabId("t1").build()) + assertEquals("", favicon.value) + assertEquals(listOf("activeTabs", "getTabUrl", "refreshTabs", "getFaviconCacheKey"), provider.calls) + } + + @Test + fun `an authenticated caller can select, create and close tabs`() = + runBlocking { + authenticated.selectTab( + SelectTabRequest + .newBuilder() + .setTabId("t1") + .setPanelId("p1") + .build(), + ) + authenticated.createBrowserTab(CreateBrowserTabRequest.newBuilder().setUrl("https://example.com").build()) + authenticated.closeTab(TabIdRequest.newBuilder().setTabId("t1").build()) + + assertEquals(listOf("selectTab", "createBrowserTab", "closeTab"), provider.calls) + } + + @Test + fun `revoked caller cannot invoke a unary RPC`() = + runBlocking { + tokenRegistry.revoke(CALLER) + assertRefused { authenticated.refreshTabs(Empty.getDefaultInstance()) } + assertTrue(provider.calls.isEmpty()) + } + + @Test + fun `revoked watcher cannot receive a later snapshot`() = + runBlocking { + withTimeout(10_000) { + supervisorScope { + val received = Channel(Channel.UNLIMITED) + val watching = + async { + authenticated.watchActiveTabs(Empty.getDefaultInstance()).collect { received.send(Unit) } + } + try { + received.receive() + tokenRegistry.revoke(CALLER) + provider.setTabs(listOf(tab("after-revocation"))) + val failure = assertFailsWith { watching.await() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(received.tryReceive().isFailure, "revocation must prevent the next snapshot") + } finally { + watching.cancel() + received.close() + } + } + } + } + + private suspend fun assertRefused(call: suspend () -> Unit) { + val failure = assertFailsWith { call() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + } + + private fun tab( + tabId: String, + url: String? = null, + ): ActiveTabData = + ActiveTabData( + tabId = tabId, + typeId = "browser", + title = "Test tab", + workspaceId = "w1", + workspaceName = "Workspace", + panelId = "p1", + windowId = "win1", + splitPosition = null, + url = url, + faviconCacheKey = null, + ) + + /** Records every method it was actually asked to perform, so a refusal can be proven silent. */ + private class FakeActiveTabsProvider : ActiveTabsProvider { + val calls = mutableListOf() + private val _activeTabs = MutableStateFlow>(emptyList()) + override val activeTabs: StateFlow> + get() { + calls += "activeTabs" + return _activeTabs + } + + fun setTabs(tabs: List) { + _activeTabs.value = tabs + } + + override suspend fun refreshTabs() { + calls += "refreshTabs" + } + + override fun selectTab( + tabId: String, + panelId: String, + ) { + calls += "selectTab" + } + + override fun getTabUrl(tabId: String): String? { + calls += "getTabUrl" + return _activeTabs.value.find { it.tabId == tabId }?.url + } + + override fun getFaviconCacheKey(tabId: String): String? { + calls += "getFaviconCacheKey" + return _activeTabs.value.find { it.tabId == tabId }?.faviconCacheKey + } + + override fun createBrowserTab( + url: String, + title: String, + ): String? { + calls += "createBrowserTab" + return "new-tab-id" + } + + override fun closeTab(tabId: String): Boolean { + calls += "closeTab" + return true + } + + @Composable + override fun loadFavicon(cacheKey: String?): Painter? = null + + override fun getFallbackIcon(typeId: String): ImageVector? = null + + override fun getBrowserIntegration(tabId: String): BrowserIntegration? = null + } + + private companion object { + const val CALLER = "tabs-panel-plugin" + const val SHUTDOWN_TIMEOUT_MS = 5_000L + } +}