Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ActiveTabListResponse> =
flow {
override fun watchActiveTabs(request: Empty): Flow<ActiveTabListResponse> {
// 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()
Expand All @@ -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()
Expand All @@ -55,6 +88,7 @@ class ActiveTabsServiceBridge(
}

override suspend fun getFaviconCacheKey(request: TabIdRequest): ActiveTabStringResponse {
authenticatedCallerOrRefuse("getFaviconCacheKey")
val key = provider.getFaviconCacheKey(request.tabId)
return ActiveTabStringResponse
.newBuilder()
Expand All @@ -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()
Expand All @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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<Unit>(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<StatusException> { 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<StatusException> { 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<String>()
private val _activeTabs = MutableStateFlow<List<ActiveTabData>>(emptyList())
override val activeTabs: StateFlow<List<ActiveTabData>>
get() {
calls += "activeTabs"
return _activeTabs
}

fun setTabs(tabs: List<ActiveTabData>) {
_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
}
}