From 48b8c5a943178dcc7eeaf75dd3730b2a9714643d Mon Sep 17 00:00:00 2001 From: Antriksh Date: Mon, 14 Sep 2026 22:48:31 +0530 Subject: [PATCH 1/2] fix(kernel): require verified caller identity for the split-view bridge BossConsole#53: SplitViewServiceBridge had no identity check at all. Any process able to open a connection to the kernel IPC server - not only the plugins the host itself loaded - could force an arbitrary window's active panel to navigate to an attacker-chosen URL via openUrlInActivePanel (with no confirmation for an ordinary http(s) URL, and no confirmation gate at all for most boss:// hosts), open an arbitrary file path with no confinement via openFileInActivePanel/ openFileInEditor/openFileInBrowser/openFileAtPosition, manipulate panel/tab selection, or overwrite a workspace snapshot under an attacker-chosen name via preserveCurrentState. Applies the same fail-closed pattern already used for ActiveTabs, Download, Git, Log, PluginUI, RoleManagement, RunConfig and Supabase: every unary RPC requires a verified AUTHENTICATED_PROCESS_ID, and every mutation gets an attributed audit log line. This is the last bridge in this PR's own scope; ProjectData's identical fix is in flight separately (#666). New SplitViewServiceBridgeTest against a real gRPC server: every RPC refused with no credential and never reaching the provider, an authenticated caller can drive every operation, and a revoked caller cannot invoke a unary RPC. --- .../services/ActiveTabsServiceBridge.kt | 4 +- .../kernel/services/SplitViewServiceBridge.kt | 82 +++++ .../services/SplitViewServiceBridgeTest.kt | 318 ++++++++++++++++++ 3 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridgeTest.kt 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 0398ab8c80..5d7e7bacca 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 @@ -24,8 +24,8 @@ import kotlinx.coroutines.flow.flow * [createBrowserTab] to force the browser to navigate to an arbitrary URL, or [closeTab]/ * [selectTab] to manipulate what the user is looking at. * - * These guards protect this service only. SplitView and ProjectData still expose unguarded - * operations on the same server; BossConsole#53 tracks the remaining IPC surface. + * These guards protect this service only. ProjectData still exposes unguarded operations on the + * same server (a separate fix in flight); BossConsole#53 tracks the remaining IPC surface. */ // One method per RPC the generated service base class declares, plus small identity and audit helpers. @Suppress("TooManyFunctions") diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridge.kt index d089262156..30712242e3 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridge.kt @@ -1,55 +1,137 @@ 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.SplitViewOperations +import ai.rever.boss.plugin.logging.BossLogger +import ai.rever.boss.plugin.logging.LogCategory +import io.grpc.Status +import io.grpc.StatusException +/** + * Kernel-side bridge for `SplitViewService`. + * + * **Every call requires a verified caller identity (BossConsole#53)**, the same requirement and + * helper shape introduced for the Secret Service in PR #505 and since applied to ActiveTabs, + * Download, Git, Log, PluginUI, ProjectData, RoleManagement, RunConfig and Supabase. 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 [openUrlInActivePanel] to force the browser + * to navigate an arbitrary window's active panel to an attacker-chosen URL, with no confirmation + * of any kind for an ordinary http(s) URL. A `boss://` URL routes through + * [ai.rever.boss.components.plugin.providers.SplitViewOperationsImpl]'s deep-link dispatch as + * [ai.rever.boss.utils.DeepLinkOrigin.EXTERNAL] - `boss://terminal` gets that tier's confirmation + * prompt, but per this repo's own AGENTS.md every other `boss://` host, `boss://plugin?id=…` + * included, is unchanged by origin and has no confirmation gate at all. The same unauthenticated + * caller could also + * [openFileInActivePanel]/[openFileInEditor]/[openFileInBrowser]/[openFileAtPosition] to open an + * arbitrary file path in the user's editor or browser with no path confinement, or + * [preserveCurrentState] to overwrite a workspace snapshot under an attacker-chosen name. + * + * This closes the last bridge in this PR's own scope. ProjectData's identical fix is in flight + * separately; once both land, every bridge on the kernel IPC server BossConsole#53 named requires + * a verified caller identity. + */ +// One method per RPC the generated service base class declares, plus small identity and audit helpers. +@Suppress("TooManyFunctions") class SplitViewServiceBridge( private val provider: SplitViewOperations, ) : SplitViewServiceGrpcKt.SplitViewServiceCoroutineImplBase() { override suspend fun openUrlInActivePanel(request: SplitViewOpenUrlRequest): Empty { + val caller = authenticatedCallerOrRefuse("openUrlInActivePanel") + logMutation("openUrlInActivePanel", caller) provider.openUrlInActivePanel(request.url, request.title, request.forceNewTab) return Empty.getDefaultInstance() } override suspend fun openFileInActivePanel(request: SplitViewOpenFileRequest): Empty { + val caller = authenticatedCallerOrRefuse("openFileInActivePanel") + logMutation("openFileInActivePanel", caller) provider.openFileInActivePanel(request.filePath, request.fileName) return Empty.getDefaultInstance() } override suspend fun openFileInBrowser(request: SplitViewOpenFileRequest): Empty { + val caller = authenticatedCallerOrRefuse("openFileInBrowser") + logMutation("openFileInBrowser", caller) provider.openFileInBrowser(request.filePath, request.fileName) return Empty.getDefaultInstance() } override suspend fun openFileInEditor(request: SplitViewOpenFileRequest): Empty { + val caller = authenticatedCallerOrRefuse("openFileInEditor") + logMutation("openFileInEditor", caller) provider.openFileInEditor(request.filePath, request.fileName) return Empty.getDefaultInstance() } override suspend fun openFileAtPosition(request: SplitViewOpenFileAtPositionRequest): Empty { + val caller = authenticatedCallerOrRefuse("openFileAtPosition") + logMutation("openFileAtPosition", caller) provider.openFileAtPosition(request.filePath, request.fileName, request.line, request.column) return Empty.getDefaultInstance() } override suspend fun setActivePanel(request: SplitViewPanelIdRequest): Empty { + val caller = authenticatedCallerOrRefuse("setActivePanel") + logMutation("setActivePanel", caller) provider.setActivePanel(request.panelId) return Empty.getDefaultInstance() } override suspend fun preserveCurrentState(request: SplitViewPreserveStateRequest): Empty { + val caller = authenticatedCallerOrRefuse("preserveCurrentState") + logMutation("preserveCurrentState", caller) provider.preserveCurrentState(request.workspaceId, request.workspaceName) return Empty.getDefaultInstance() } override suspend fun selectTabInPanel(request: SplitViewSelectTabInPanelRequest): Empty { + val caller = authenticatedCallerOrRefuse("selectTabInPanel") + logMutation("selectTabInPanel", caller) provider.selectTabInPanel(request.tabId, request.panelId) return Empty.getDefaultInstance() } override suspend fun applyWorkspace(request: SplitViewApplyWorkspaceRequest): Empty { + authenticatedCallerOrRefuse("applyWorkspace") // Workspace JSON needs to be deserialized on the host side // For now, log the request — full implementation depends on LayoutWorkspace serialization return Empty.getDefaultInstance() } + + /** + * 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 split-view mutation requested", + mapOf("rpc" to rpc, "caller" to caller), + ) + } + + private fun refuseIdentity(rpc: String): Nothing { + logger.warn( + LogCategory.AUTH, + "Refused $rpc: no current verified process identity on this call", + mapOf("rpc" to rpc), + ) + throw StatusException(Status.PERMISSION_DENIED.withDescription(NO_IDENTITY)) + } + + private companion object { + val logger = BossLogger.forComponent("SplitViewServiceBridge") + + const val NO_IDENTITY = "This call presented no verified process identity" + } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridgeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridgeTest.kt new file mode 100644 index 0000000000..b56353dc56 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridgeTest.kt @@ -0,0 +1,318 @@ +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.SplitViewApplyWorkspaceRequest +import ai.rever.boss.ipc.proto.services.SplitViewOpenFileAtPositionRequest +import ai.rever.boss.ipc.proto.services.SplitViewOpenFileRequest +import ai.rever.boss.ipc.proto.services.SplitViewOpenUrlRequest +import ai.rever.boss.ipc.proto.services.SplitViewPanelIdRequest +import ai.rever.boss.ipc.proto.services.SplitViewPreserveStateRequest +import ai.rever.boss.ipc.proto.services.SplitViewSelectTabInPanelRequest +import ai.rever.boss.ipc.proto.services.SplitViewServiceGrpcKt +import ai.rever.boss.plugin.api.SplitViewOperations +import ai.rever.boss.plugin.api.TabInfo +import ai.rever.boss.plugin.api.TabSplitMode +import ai.rever.boss.plugin.api.TabsComponent +import ai.rever.boss.plugin.workspace.LayoutWorkspace +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.runBlocking +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 split-view 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 force an arbitrary window's active + * panel to navigate to an attacker-chosen URL via `openUrlInActivePanel`, open an arbitrary file + * path with no confinement via `openFileInActivePanel`/`openFileInEditor`/`openFileInBrowser`/ + * `openFileAtPosition`, or overwrite a workspace snapshot under an attacker-chosen name via + * `preserveCurrentState`. + */ +class SplitViewServiceBridgeTest { + private lateinit var tokenRegistry: ProcessTokenRegistry + private lateinit var provider: FakeSplitViewOperations + private lateinit var server: Server + private lateinit var authenticatedChannel: ManagedChannel + private lateinit var anonymousChannel: ManagedChannel + private lateinit var authenticated: SplitViewServiceGrpcKt.SplitViewServiceCoroutineStub + private lateinit var anonymous: SplitViewServiceGrpcKt.SplitViewServiceCoroutineStub + + @BeforeTest + fun setUp() { + tokenRegistry = ProcessTokenRegistry() + provider = FakeSplitViewOperations() + server = + ServerBuilder + .forPort(0) + .intercept(ProcessIdentityInterceptor(tokenRegistry)) + .addService(SplitViewServiceBridge(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 = SplitViewServiceGrpcKt.SplitViewServiceCoroutineStub(authenticatedChannel) + anonymous = SplitViewServiceGrpcKt.SplitViewServiceCoroutineStub(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 provider`() = + runBlocking { + assertRefused { + anonymous.openUrlInActivePanel( + SplitViewOpenUrlRequest.newBuilder().setUrl("https://evil.example").build(), + ) + } + assertRefused { + anonymous.openFileInActivePanel( + SplitViewOpenFileRequest.newBuilder().setFilePath("/etc/passwd").build(), + ) + } + assertRefused { + anonymous.openFileInBrowser(SplitViewOpenFileRequest.newBuilder().setFilePath("/etc/passwd").build()) + } + assertRefused { + anonymous.openFileInEditor(SplitViewOpenFileRequest.newBuilder().setFilePath("/etc/passwd").build()) + } + assertRefused { + anonymous.openFileAtPosition( + SplitViewOpenFileAtPositionRequest.newBuilder().setFilePath("/etc/passwd").build(), + ) + } + assertRefused { anonymous.setActivePanel(SplitViewPanelIdRequest.newBuilder().setPanelId("p1").build()) } + assertRefused { + anonymous.preserveCurrentState( + SplitViewPreserveStateRequest + .newBuilder() + .setWorkspaceId("w1") + .setWorkspaceName("evil") + .build(), + ) + } + assertRefused { + anonymous.selectTabInPanel( + SplitViewSelectTabInPanelRequest + .newBuilder() + .setTabId("t1") + .setPanelId("p1") + .build(), + ) + } + assertRefused { + anonymous.applyWorkspace(SplitViewApplyWorkspaceRequest.newBuilder().setWorkspaceJson("{}").build()) + } + + assertTrue(provider.calls.isEmpty(), "a refused call must never reach the provider") + } + + @Test + fun `an authenticated caller can open a URL, files and position`() = + runBlocking { + authenticated.openUrlInActivePanel( + SplitViewOpenUrlRequest + .newBuilder() + .setUrl("https://example.com") + .setTitle("t") + .build(), + ) + authenticated.openFileInActivePanel( + SplitViewOpenFileRequest + .newBuilder() + .setFilePath("/tmp/a") + .setFileName("a") + .build(), + ) + authenticated.openFileInBrowser( + SplitViewOpenFileRequest + .newBuilder() + .setFilePath("/tmp/a") + .setFileName("a") + .build(), + ) + authenticated.openFileInEditor( + SplitViewOpenFileRequest + .newBuilder() + .setFilePath("/tmp/a") + .setFileName("a") + .build(), + ) + authenticated.openFileAtPosition( + SplitViewOpenFileAtPositionRequest + .newBuilder() + .setFilePath("/tmp/a") + .setFileName("a") + .setLine(1) + .setColumn(2) + .build(), + ) + + assertEquals( + listOf( + "openUrlInActivePanel", + "openFileInActivePanel", + "openFileInBrowser", + "openFileInEditor", + "openFileAtPosition", + ), + provider.calls, + ) + } + + @Test + fun `an authenticated caller can drive panel and workspace state`() = + runBlocking { + authenticated.setActivePanel(SplitViewPanelIdRequest.newBuilder().setPanelId("p1").build()) + authenticated.preserveCurrentState( + SplitViewPreserveStateRequest + .newBuilder() + .setWorkspaceId("w1") + .setWorkspaceName("n") + .build(), + ) + authenticated.selectTabInPanel( + SplitViewSelectTabInPanelRequest + .newBuilder() + .setTabId("t1") + .setPanelId("p1") + .build(), + ) + authenticated.applyWorkspace( + SplitViewApplyWorkspaceRequest.newBuilder().setWorkspaceJson("{}").build(), + ) + + assertEquals( + listOf("setActivePanel", "preserveCurrentState", "selectTabInPanel"), + provider.calls, + ) + } + + @Test + fun `revoked caller cannot invoke a unary RPC`() = + runBlocking { + tokenRegistry.revoke(CALLER) + assertRefused { + authenticated.setActivePanel(SplitViewPanelIdRequest.newBuilder().setPanelId("p1").build()) + } + assertTrue(provider.calls.isEmpty()) + } + + private suspend fun assertRefused(call: suspend () -> Unit) { + val failure = assertFailsWith { call() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + } + + /** Records every method it was actually asked to perform, so a refusal can be proven silent. */ + private class FakeSplitViewOperations : SplitViewOperations { + val calls = mutableListOf() + + override fun openUrlInActivePanel( + url: String, + title: String, + forceNewTab: Boolean, + ) { + calls += "openUrlInActivePanel" + } + + override fun openFileInActivePanel( + filePath: String, + fileName: String, + ) { + calls += "openFileInActivePanel" + } + + override fun openFileInBrowser( + filePath: String, + fileName: String, + ) { + calls += "openFileInBrowser" + } + + override fun openFileInEditor( + filePath: String, + fileName: String, + ) { + calls += "openFileInEditor" + } + + override fun openFileAtPosition( + filePath: String, + fileName: String, + line: Int, + column: Int, + ) { + calls += "openFileAtPosition" + } + + override fun setActivePanel(panelId: String) { + calls += "setActivePanel" + } + + override fun preserveCurrentState( + workspaceId: String, + workspaceName: String, + ) { + calls += "preserveCurrentState" + } + + override fun getActiveTabsComponent(): TabsComponent? = null + + override fun applyWorkspace(workspace: LayoutWorkspace) = Unit + + override fun selectTabInPanel( + tabId: String, + panelId: String, + ) { + calls += "selectTabInPanel" + } + + override fun openTab(tabInfo: TabInfo) = Unit + + override fun openTabInSplit( + tabInfo: TabInfo, + mode: TabSplitMode, + ) = Unit + + override val supportsOpenPanelAsTab: Boolean = false + + override fun openPanelAsTab(panelId: ai.rever.boss.plugin.api.PanelId) = Unit + + override fun openUrlInSplit( + url: String, + title: String, + mode: TabSplitMode, + ) = Unit + } + + private companion object { + const val CALLER = "split-view-plugin" + const val SHUTDOWN_TIMEOUT_MS = 5_000L + } +} From 33b3d035bf9734a7e9d15fbeb73ecdd3b58b7788 Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 17:28:07 -0700 Subject: [PATCH 2/2] test(kernel): verify split-view identity coverage and clarify scope --- .../services/ActiveTabsServiceBridge.kt | 4 +- .../kernel/services/SplitViewServiceBridge.kt | 28 ++----- .../services/SplitViewServiceBridgeTest.kt | 79 +++++++++++++++---- .../kotlin/ai/rever/boss/ipc/BossIpcServer.kt | 9 +-- 4 files changed, 77 insertions(+), 43 deletions(-) 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 5d7e7bacca..f9db7702cd 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 @@ -24,8 +24,8 @@ import kotlinx.coroutines.flow.flow * [createBrowserTab] to force the browser to navigate to an arbitrary URL, or [closeTab]/ * [selectTab] to manipulate what the user is looking at. * - * These guards protect this service only. ProjectData still exposes unguarded operations on the - * same server (a separate fix in flight); BossConsole#53 tracks the remaining IPC surface. + * These guards protect this service only. Other bridges require their own identity checks; + * BossConsole#53 tracks the remaining IPC surface. */ // One method per RPC the generated service base class declares, plus small identity and audit helpers. @Suppress("TooManyFunctions") diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridge.kt index 30712242e3..d3fda6fa8e 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridge.kt @@ -10,27 +10,12 @@ import io.grpc.Status import io.grpc.StatusException /** - * Kernel-side bridge for `SplitViewService`. + * Host-side split-view operations for authenticated child processes (BossConsole#53). * - * **Every call requires a verified caller identity (BossConsole#53)**, the same requirement and - * helper shape introduced for the Secret Service in PR #505 and since applied to ActiveTabs, - * Download, Git, Log, PluginUI, ProjectData, RoleManagement, RunConfig and Supabase. 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 [openUrlInActivePanel] to force the browser - * to navigate an arbitrary window's active panel to an attacker-chosen URL, with no confirmation - * of any kind for an ordinary http(s) URL. A `boss://` URL routes through - * [ai.rever.boss.components.plugin.providers.SplitViewOperationsImpl]'s deep-link dispatch as - * [ai.rever.boss.utils.DeepLinkOrigin.EXTERNAL] - `boss://terminal` gets that tier's confirmation - * prompt, but per this repo's own AGENTS.md every other `boss://` host, `boss://plugin?id=…` - * included, is unchanged by origin and has no confirmation gate at all. The same unauthenticated - * caller could also - * [openFileInActivePanel]/[openFileInEditor]/[openFileInBrowser]/[openFileAtPosition] to open an - * arbitrary file path in the user's editor or browser with no path confinement, or - * [preserveCurrentState] to overwrite a workspace snapshot under an attacker-chosen name. - * - * This closes the last bridge in this PR's own scope. ProjectData's identical fix is in flight - * separately; once both land, every bridge on the kernel IPC server BossConsole#53 named requires - * a verified caller identity. + * Every unary RPC requires the process identity verified by the kernel's interceptor before + * accessing the provider. Implemented mutations log the operation and caller, without exposing + * URLs or file paths. This is process authentication, not a per-plugin capability or path policy. + * Other kernel services require their own guards; this bridge does not secure the entire server. */ // One method per RPC the generated service base class declares, plus small identity and audit helpers. @Suppress("TooManyFunctions") @@ -95,12 +80,15 @@ class SplitViewServiceBridge( override suspend fun applyWorkspace(request: SplitViewApplyWorkspaceRequest): Empty { authenticatedCallerOrRefuse("applyWorkspace") + // Add mutation auditing when this stub gains provider dispatch. // Workspace JSON needs to be deserialized on the host side // For now, log the request — full implementation depends on LayoutWorkspace serialization return Empty.getDefaultInstance() } /** + * Unary RPCs only: this identity is a per-call snapshot. Streaming RPCs must revalidate + * with `CURRENT_IDENTITY`, as PluginUIServiceBridge.streamUI does. * 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 diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridgeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridgeTest.kt index b56353dc56..14393669bd 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridgeTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridgeTest.kt @@ -1,5 +1,6 @@ package ai.rever.boss.kernel.services +import ai.rever.boss.ipc.BossIpcServer import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor import ai.rever.boss.ipc.auth.ProcessTokenClientInterceptor import ai.rever.boss.ipc.auth.ProcessTokenRegistry @@ -19,8 +20,12 @@ import ai.rever.boss.plugin.api.TabsComponent import ai.rever.boss.plugin.workspace.LayoutWorkspace import io.grpc.ManagedChannel import io.grpc.ManagedChannelBuilder +import io.grpc.Metadata import io.grpc.Server import io.grpc.ServerBuilder +import io.grpc.ServerCall +import io.grpc.ServerCallHandler +import io.grpc.ServerInterceptor import io.grpc.Status import io.grpc.StatusException import kotlinx.coroutines.runBlocking @@ -43,6 +48,7 @@ import kotlin.test.assertTrue * `preserveCurrentState`. */ class SplitViewServiceBridgeTest { + private val exercisedRpcs = java.util.Collections.synchronizedSet(mutableSetOf()) private lateinit var tokenRegistry: ProcessTokenRegistry private lateinit var provider: FakeSplitViewOperations private lateinit var server: Server @@ -59,7 +65,18 @@ class SplitViewServiceBridgeTest { ServerBuilder .forPort(0) .intercept(ProcessIdentityInterceptor(tokenRegistry)) - .addService(SplitViewServiceBridge(provider)) + .intercept( + object : ServerInterceptor { + override fun interceptCall( + call: ServerCall, + headers: Metadata, + next: ServerCallHandler, + ): ServerCall.Listener { + exercisedRpcs += call.methodDescriptor.fullMethodName + return next.startCall(call, headers) + } + }, + ).addService(SplitViewServiceBridge(provider)) .build() .start() authenticatedChannel = @@ -131,6 +148,15 @@ class SplitViewServiceBridgeTest { } assertTrue(provider.calls.isEmpty(), "a refused call must never reach the provider") + assertEquals( + SplitViewServiceBridge(provider) + .bindService() + .methods + .map { it.methodDescriptor.fullMethodName } + .toSet(), + exercisedRpcs.toSet(), + "Every declared RPC must be exercised by the refusal test", + ) } @Test @@ -141,6 +167,7 @@ class SplitViewServiceBridgeTest { .newBuilder() .setUrl("https://example.com") .setTitle("t") + .setForceNewTab(true) .build(), ) authenticated.openFileInActivePanel( @@ -176,11 +203,11 @@ class SplitViewServiceBridgeTest { assertEquals( listOf( - "openUrlInActivePanel", - "openFileInActivePanel", - "openFileInBrowser", - "openFileInEditor", - "openFileAtPosition", + "openUrlInActivePanel|https://example.com|t|true", + "openFileInActivePanel|/tmp/a|a", + "openFileInBrowser|/tmp/a|a", + "openFileInEditor|/tmp/a|a", + "openFileAtPosition|/tmp/a|a|1|2", ), provider.calls, ) @@ -209,7 +236,7 @@ class SplitViewServiceBridgeTest { ) assertEquals( - listOf("setActivePanel", "preserveCurrentState", "selectTabInPanel"), + listOf("setActivePanel|p1", "preserveCurrentState|w1|n", "selectTabInPanel|t1|p1"), provider.calls, ) } @@ -224,6 +251,28 @@ class SplitViewServiceBridgeTest { assertTrue(provider.calls.isEmpty()) } + @Test + fun `late registered production bridge rejects anonymous and accepts authenticated calls`() = + runBlocking { + val lateServer = BossIpcServer("tcp://localhost:0", tokenRegistry).start() + val channel = ManagedChannelBuilder.forAddress("localhost", lateServer.port).usePlaintext().build() + try { + lateServer.addService(SplitViewServiceBridge(provider)) + val stub = SplitViewServiceGrpcKt.SplitViewServiceCoroutineStub(channel) + val request = SplitViewPanelIdRequest.newBuilder().setPanelId("late-panel").build() + assertRefused { stub.setActivePanel(request) } + assertTrue(provider.calls.isEmpty()) + stub + .withInterceptors(ProcessTokenClientInterceptor(tokenRegistry.issue("late-caller"))) + .setActivePanel(request) + assertEquals(listOf("setActivePanel|late-panel"), provider.calls) + } finally { + channel.shutdownNow() + channel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + lateServer.stop(SHUTDOWN_TIMEOUT_MS) + } + } + private suspend fun assertRefused(call: suspend () -> Unit) { val failure = assertFailsWith { call() } assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) @@ -238,28 +287,28 @@ class SplitViewServiceBridgeTest { title: String, forceNewTab: Boolean, ) { - calls += "openUrlInActivePanel" + calls += "openUrlInActivePanel|$url|$title|$forceNewTab" } override fun openFileInActivePanel( filePath: String, fileName: String, ) { - calls += "openFileInActivePanel" + calls += "openFileInActivePanel|$filePath|$fileName" } override fun openFileInBrowser( filePath: String, fileName: String, ) { - calls += "openFileInBrowser" + calls += "openFileInBrowser|$filePath|$fileName" } override fun openFileInEditor( filePath: String, fileName: String, ) { - calls += "openFileInEditor" + calls += "openFileInEditor|$filePath|$fileName" } override fun openFileAtPosition( @@ -268,18 +317,18 @@ class SplitViewServiceBridgeTest { line: Int, column: Int, ) { - calls += "openFileAtPosition" + calls += "openFileAtPosition|$filePath|$fileName|$line|$column" } override fun setActivePanel(panelId: String) { - calls += "setActivePanel" + calls += "setActivePanel|$panelId" } override fun preserveCurrentState( workspaceId: String, workspaceName: String, ) { - calls += "preserveCurrentState" + calls += "preserveCurrentState|$workspaceId|$workspaceName" } override fun getActiveTabsComponent(): TabsComponent? = null @@ -290,7 +339,7 @@ class SplitViewServiceBridgeTest { tabId: String, panelId: String, ) { - calls += "selectTabInPanel" + calls += "selectTabInPanel|$tabId|$panelId" } override fun openTab(tabInfo: TabInfo) = Unit diff --git a/modules/boss-ipc/src/main/kotlin/ai/rever/boss/ipc/BossIpcServer.kt b/modules/boss-ipc/src/main/kotlin/ai/rever/boss/ipc/BossIpcServer.kt index 9f5e953750..3d18850f79 100644 --- a/modules/boss-ipc/src/main/kotlin/ai/rever/boss/ipc/BossIpcServer.kt +++ b/modules/boss-ipc/src/main/kotlin/ai/rever/boss/ipc/BossIpcServer.kt @@ -34,12 +34,9 @@ class BossIpcServer( * instead of trusting a request field (BossConsole#53). Null (the default) installs no interceptor, * which is a no-op for a service that never checks identity - most `BossIpcServer` instances (every * child process's own `processServer`, every test server) have no use for one. It stops being a - * no-op for a server hosting a service that fails closed on a missing identity (currently - * [ai.rever.boss.kernel.services.SecretServiceBridge] and - * [ai.rever.boss.kernel.services.RoleManagementServiceBridge]): with no registry, every call to - * that service is refused, not merely unauthenticated as before - a silent total outage rather - * than the pre-BossConsole#53 status quo. Any kernel server hosting one of those bridges must - * be constructed with a real registry. + * no-op for a server hosting any service that requires a verified process identity: with no + * registry, every guarded call is refused. Any kernel server hosting identity-enforcing + * bridges must be constructed with a real registry, including services registered after start. */ private val tokenRegistry: ProcessTokenRegistry? = null, ) {