From a01fe681f7fecd1251d86e2e726e0fa2f50c6675 Mon Sep 17 00:00:00 2001 From: Raghav Bansal <273537979+Raghav-Bansal-15@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:39:56 +0530 Subject: [PATCH 1/8] Authenticate runtime IPC and remove credentials from subprocesses --- .github/workflows/build.yml | 48 ++++-- .github/workflows/release.yml | 19 +++ build.gradle.kts | 55 +++++-- gradle.properties | 2 +- .../runtime/stateholders/ProcessRunner.kt | 3 + .../META-INF/boss-runtime/security-contract | 1 + .../stateholders/EnvironmentProbe.java | 14 ++ .../runtime/PackagedRuntimeSecurityTest.kt | 146 ++++++++++++++++++ .../plugin/runtime/RuntimeFixturePlugin.kt | 21 +++ .../ProcessRunnerCredentialsTest.kt | 56 +++++++ 10 files changed, 334 insertions(+), 31 deletions(-) create mode 100644 src/main/resources/META-INF/boss-runtime/security-contract create mode 100644 src/test/java/ai/rever/boss/plugin/runtime/stateholders/EnvironmentProbe.java create mode 100644 src/test/kotlin/ai/rever/boss/plugin/runtime/PackagedRuntimeSecurityTest.kt create mode 100644 src/test/kotlin/ai/rever/boss/plugin/runtime/RuntimeFixturePlugin.kt create mode 100644 src/test/kotlin/ai/rever/boss/plugin/runtime/stateholders/ProcessRunnerCredentialsTest.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a27b2a3..54d58e0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,31 +1,47 @@ name: Build -# Compiles and tests on every PR and push to main. -# -# There was no such job until now, and that is how a cross-repo break went -# unnoticed: the release workflow only runs `fatJar` (main sources, on demand), -# so a contract change in BossConsole that this repo could no longer compile -# against only surfaced at the next release attempt. `build` covers the test -# source set too, which is where the transport direction is asserted. on: pull_request: push: - branches: [main] + branches: [main, fix/authenticated-runtime] + +permissions: + contents: read jobs: build: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - + with: + path: runtime + # Immutable companion host implementation from BossConsole #575. + # Keep this until equivalent authenticated IPC artifacts are published. + - uses: actions/checkout@v4 + with: + repository: risa-labs-inc/BossConsole + ref: aa750ce9d2db91bcfad06e01dd1f8cb7b6b12bb8 + path: host - uses: actions/setup-java@v4 with: distribution: temurin java-version: '17' - - uses: gradle/actions/setup-gradle@v4 - - # CI=true makes build.gradle.kts skip the sibling-checkout branch and - # download the contract jars instead. - - name: Build and test - run: ./gradlew build --no-daemon --stacktrace + - name: Build the companion host contract + shell: bash + working-directory: host + run: ./gradlew assembleUpstreamJars --no-daemon + - name: Build and test the packaged runtime + shell: bash + working-directory: runtime + run: ./gradlew build fatJar -Pbossconsole.dir=../host --no-daemon --stacktrace + - uses: actions/upload-artifact@v4 + if: always() + with: + name: runtime-tests-${{ matrix.os }} + path: runtime/build/test-results/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56d6ab9..7f569b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,9 @@ concurrency: jobs: release: + # Older hosts ignore minBossVersion on the GitHub runtime download path. + # Maintainers must complete compatible distribution before enabling this rollout. + if: ${{ vars.AUTHENTICATED_IPC_ROLLOUT_APPROVED == 'true' }} runs-on: ubuntu-latest permissions: contents: write @@ -63,6 +66,22 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + - name: Require the coordinated minimum host release + env: + MIN_BOSS_VERSION: ${{ vars.AUTHENTICATED_IPC_MIN_BOSS_VERSION }} + run: | + python3 - <<'PYTHON' + import json, os, re + from pathlib import Path + version = os.environ.get("MIN_BOSS_VERSION", "") + if not re.fullmatch(r"[0-9]+(?:\.[0-9]+){2,3}(?:-[0-9A-Za-z.-]+)?", version): + raise SystemExit("Set AUTHENTICATED_IPC_MIN_BOSS_VERSION to the approved host release") + path = Path("src/main/resources/META-INF/boss-plugin/plugin.json") + manifest = json.loads(path.read_text()) + manifest["minBossVersion"] = version + path.write_text(json.dumps(manifest, indent=2) + "\n") + PYTHON + - name: Bump runtime version in plugin.json # On push: always patch-bump so same-version re-publish doesn't 400 # against the plugin store. On dispatch: use the user's choice and diff --git a/build.gradle.kts b/build.gradle.kts index 73fec17..b55f3c7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -67,11 +67,15 @@ val bossIpcVersion: String = providers.gradleProperty("ipc.version").orElse("1.0 val contractVersion: String = providers.gradleProperty("contract.version").orElse("1.0.0").get() // CI: jars downloaded into build/downloaded-deps/ by the `downloadDeps` task before compile. -val useLocalDependencies: Boolean = (System.getenv("CI") != "true") && - file("../../BossConsole/build/upstream-artifacts/boss-ipc-$bossIpcVersion.jar").exists() +val explicitBossConsoleDir = providers.gradleProperty("bossconsole.dir") + .orElse(providers.environmentVariable("BOSSCONSOLE_DIR")) +val bossConsoleDir = file(explicitBossConsoleDir.orElse("../../BossConsole").get()) +val localUpstreamDir = bossConsoleDir.resolve("build/upstream-artifacts") +val useLocalDependencies: Boolean = explicitBossConsoleDir.isPresent || + (System.getenv("CI") != "true" && localUpstreamDir.resolve("boss-ipc-$bossIpcVersion.jar").exists()) val upstreamJarDir: File = if (useLocalDependencies) { - file("../../BossConsole/build/upstream-artifacts") + localUpstreamDir } else { file("build/downloaded-deps") } @@ -96,7 +100,7 @@ val upstreamJars = listOf( // contract into its own classloader, and a child JVM built against a different revision of it is // exactly the mismatch the IPC compat gate cannot see. val bossPluginApiVersion: String = - providers.gradleProperty("boss.plugin.api.version").orElse("1.0.68").get() + providers.gradleProperty("boss.plugin.api.version").orElse("1.0.88").get() val bossPluginApiJarName = "boss-plugin-api-$bossPluginApiVersion.jar" // Resolution order mirrors the upstream jars: locally built sibling first (both live under @@ -104,7 +108,7 @@ val bossPluginApiJarName = "boss-plugin-api-$bossPluginApiVersion.jar" val bossPluginApiCandidates = listOf( file("../boss-plugin-api/build/libs/$bossPluginApiJarName"), - file("../../BossConsole/plugin-platform/plugin-api-core/build/api-contract/$bossPluginApiJarName"), + bossConsoleDir.resolve("plugin-platform/plugin-api-core/build/api-contract/$bossPluginApiJarName"), ) val bossPluginApiJar: File = bossPluginApiCandidates.firstOrNull { it.exists() } @@ -150,17 +154,19 @@ dependencies { // the boss-ipc gencode is generated with that version and the protobuf // runtime refuses to load gencode newer than itself (child JVM dies at // startup with ProtobufRuntimeVersionException). Keep in lockstep. - implementation("io.grpc:grpc-netty:1.72.0") - implementation("io.grpc:grpc-protobuf:1.72.0") - implementation("io.grpc:grpc-stub:1.72.0") - implementation("io.grpc:grpc-kotlin-stub:1.4.3") + implementation("io.grpc:grpc-netty:1.84.0") + implementation("io.grpc:grpc-protobuf:1.84.0") + implementation("io.grpc:grpc-stub:1.84.0") + implementation("io.grpc:grpc-util:1.84.0") + implementation("org.bouncycastle:bcpkix-jdk18on:1.85") + implementation("io.grpc:grpc-kotlin-stub:1.5.0") implementation("com.google.protobuf:protobuf-java:4.35.1") implementation("com.google.protobuf:protobuf-kotlin:4.35.1") - implementation("io.netty:netty-transport-native-unix-common:4.2.6.Final") - implementation("io.netty:netty-transport-native-kqueue:4.2.6.Final:osx-aarch_64") - implementation("io.netty:netty-transport-native-kqueue:4.2.6.Final:osx-x86_64") - implementation("io.netty:netty-transport-native-epoll:4.2.6.Final:linux-aarch_64") - implementation("io.netty:netty-transport-native-epoll:4.2.6.Final:linux-x86_64") + implementation("io.netty:netty-transport-native-unix-common:4.2.17.Final") + implementation("io.netty:netty-transport-native-kqueue:4.2.17.Final:osx-aarch_64") + implementation("io.netty:netty-transport-native-kqueue:4.2.17.Final:osx-x86_64") + implementation("io.netty:netty-transport-native-epoll:4.2.17.Final:linux-aarch_64") + implementation("io.netty:netty-transport-native-epoll:4.2.17.Final:linux-x86_64") // Compose runtime — bundled into fatJar so OOP plugins can use it. // Versions match BossConsole. @@ -279,6 +285,8 @@ tasks.register("fatJar") { description = "Self-contained runtime JAR for child JVM spawn (matches the legacy in-tree :fatJar output)." archiveClassifier.set("all") duplicatesStrategy = DuplicatesStrategy.EXCLUDE + // Original dependency signatures cannot authenticate a newly assembled, flattened JAR. + exclude("META-INF/*.SF", "META-INF/*.RSA", "META-INF/*.DSA") manifest { attributes["Main-Class"] = "ai.rever.boss.plugin.runtime.PluginProcessMainKt" } @@ -325,3 +333,22 @@ tasks.register("computeFatJarSha256") { File(layout.buildDirectory.get().asFile, "fatjar-size.txt").writeText(jar.length().toString()) } } + +// Exercise the packaged runtime and a tiny fixture plugin, not the Gradle test classpath. +val integrationFixtureJar = tasks.register("integrationFixtureJar") { + dependsOn(tasks.testClasses) + archiveClassifier.set("integration-fixture") + from(sourceSets.test.get().output) { + include("ai/rever/boss/plugin/runtime/RuntimeFixturePlugin*.class") + } +} + +tasks.test { + dependsOn("fatJar", integrationFixtureJar) + systemProperty("runtime.integration.jar", tasks.named("fatJar").flatMap { it.archiveFile }.get().asFile.absolutePath) + systemProperty("runtime.fixture.jar", integrationFixtureJar.flatMap { it.archiveFile }.get().asFile.absolutePath) + // Synthetic inherited authority makes subprocess scrubbing tests exercise real environment inheritance. + listOf("BOSS_PROCESS_TOKEN", "BOSS_KERNEL_TLS_CERT", "BOSS_IPC_TLS_CERT", "BOSS_IPC_TLS_KEY", "BOSS_HOST_TOKEN").forEach { + environment(it, "synthetic-inherited") + } +} diff --git a/gradle.properties b/gradle.properties index 5fad723..2900bf1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,7 +14,7 @@ ipc.version=1.0.0 # compiles and bundles. `ai.rever.boss.plugin.api.*` does NOT ship in the # BossConsole contract jars — see AGENTS.md. Keep this equal to BossConsole's # `boss-plugin-api` pin in libs.versions.toml. -boss.plugin.api.version=1.0.68 +boss.plugin.api.version=1.0.88 # Runtime release version (bumped per release by CI). runtime.version=1.0.0 diff --git a/src/main/kotlin/ai/rever/boss/plugin/runtime/stateholders/ProcessRunner.kt b/src/main/kotlin/ai/rever/boss/plugin/runtime/stateholders/ProcessRunner.kt index d910cf2..912f775 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/runtime/stateholders/ProcessRunner.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/runtime/stateholders/ProcessRunner.kt @@ -1,5 +1,6 @@ package ai.rever.boss.plugin.runtime.stateholders +import ai.rever.boss.ipc.auth.IpcEnvironment import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -83,6 +84,7 @@ internal object ProcessRunner { .joinToString(File.pathSeparator) } builder.environment().putAll(environment) + IpcEnvironment.removeCredentials(builder.environment()) val process = builder.start() // Kill the child the moment this coroutine is cancelled; closing its @@ -163,6 +165,7 @@ internal object ProcessRunner { .joinToString(File.pathSeparator) } builder.environment().putAll(environment) + IpcEnvironment.removeCredentials(builder.environment()) val process = builder.start() val killer = currentCoroutineContext().job.invokeOnCompletion { process.destroyForcibly() } diff --git a/src/main/resources/META-INF/boss-runtime/security-contract b/src/main/resources/META-INF/boss-runtime/security-contract new file mode 100644 index 0000000..f779f9e --- /dev/null +++ b/src/main/resources/META-INF/boss-runtime/security-contract @@ -0,0 +1 @@ +pinned-tls-v1;subprocess-env-v1 diff --git a/src/test/java/ai/rever/boss/plugin/runtime/stateholders/EnvironmentProbe.java b/src/test/java/ai/rever/boss/plugin/runtime/stateholders/EnvironmentProbe.java new file mode 100644 index 0000000..92fd3ad --- /dev/null +++ b/src/test/java/ai/rever/boss/plugin/runtime/stateholders/EnvironmentProbe.java @@ -0,0 +1,14 @@ +package ai.rever.boss.plugin.runtime.stateholders; + +public final class EnvironmentProbe { + public static void main(String[] args) { + String[] protectedNames = {"BOSS_PROCESS_TOKEN", "BOSS_KERNEL_TLS_CERT", "BOSS_IPC_TLS_CERT", "BOSS_IPC_TLS_KEY", "BOSS_HOST_TOKEN"}; + boolean leaked = false; + for (String key : System.getenv().keySet()) { + for (String protectedName : protectedNames) { + if (key.equalsIgnoreCase(protectedName)) leaked = true; + } + } + System.out.println((leaked ? "leaked:" : "clean:") + System.getenv("BOSS_TEST_NORMAL")); + } +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/runtime/PackagedRuntimeSecurityTest.kt b/src/test/kotlin/ai/rever/boss/plugin/runtime/PackagedRuntimeSecurityTest.kt new file mode 100644 index 0000000..b944553 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/runtime/PackagedRuntimeSecurityTest.kt @@ -0,0 +1,146 @@ +package ai.rever.boss.plugin.runtime + +import ai.rever.boss.ipc.BossIpcClient +import ai.rever.boss.ipc.BossIpcServer +import ai.rever.boss.ipc.IpcTransport +import ai.rever.boss.ipc.auth.IpcCall +import ai.rever.boss.ipc.auth.IpcClientCredentials +import ai.rever.boss.ipc.auth.IpcEnvironment +import ai.rever.boss.ipc.auth.IpcTlsIdentity +import ai.rever.boss.ipc.auth.ProcessTokenRegistry +import ai.rever.boss.ipc.proto.PluginUIServiceGrpcKt +import ai.rever.boss.ipc.proto.StateKey +import ai.rever.boss.ipc.proto.StateServiceGrpcKt +import ai.rever.boss.ipc.proto.UIEvent +import ai.rever.boss.ipc.proto.UIRegistration +import ai.rever.boss.ipc.proto.UIRegistrationResponse +import ai.rever.boss.ipc.proto.WidgetUpdate +import ai.rever.boss.ipc.services.KernelServiceImpl +import io.grpc.Status +import io.grpc.StatusException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import java.io.File +import java.net.ServerSocket +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.jar.JarEntry +import java.util.jar.JarFile +import java.util.jar.JarOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class PackagedRuntimeSecurityTest { + private class KernelUi : PluginUIServiceGrpcKt.PluginUIServiceCoroutineImplBase() { + val registrations = AtomicInteger() + val updates = AtomicInteger() + + override suspend fun registerUI(request: UIRegistration): UIRegistrationResponse { + IpcCall.requireOwnProcess(request.processId) + registrations.incrementAndGet() + return UIRegistrationResponse.newBuilder().setSuccess(true).build() + } + + override fun streamUI(requests: Flow): Flow = flow { + requests.collect { + IpcCall.requireOwnProcess("runtime-fixture") + assertEquals("runtime-security-fixture", it.surfaceId) + updates.incrementAndGet() + emit(UIEvent.getDefaultInstance()) + } + } + } + + @Test + fun `fat jar registers heartbeats serves authenticated calls and sends UI over pinned TLS`() = runBlocking { + val root = Files.createTempDirectory("packaged-runtime-").toFile() + val runtime = File(System.getProperty("runtime.integration.jar")) + IpcTransport.requireCompatibleRuntime(runtime.toPath()) + val plugin = fixturePlugin(root) + val tokens = ProcessTokenRegistry() + val kernelIdentity = IpcTlsIdentity.create() + val childIdentity = IpcTlsIdentity.create() + val childAddress = "tcp://127.0.0.1:${ServerSocket(0).use { it.localPort }}" + val processToken = tokens.issue("runtime-fixture", expectedAddress = childAddress) + val hostToken = ProcessTokenRegistry().issue("host") + val kernel = KernelServiceImpl() + val ui = KernelUi() + val server = BossIpcServer("tcp://127.0.0.1:0", tokens, kernelIdentity).addService(kernel).addService(ui).start() + val clients = mutableListOf() + var child: Process? = null + try { + val builder = ProcessBuilder( + File(System.getProperty("java.home"), "bin/java").absolutePath, + "-Xmx256m", "-cp", runtime.absolutePath + File.pathSeparator + plugin.absolutePath, + "ai.rever.boss.plugin.runtime.PluginProcessMainKt", + ).redirectOutput(File(root, "stdout.log")).redirectError(File(root, "stderr.log")) + builder.environment().putAll(mapOf( + "BOSS_KERNEL_IPC_ADDR" to "tcp://127.0.0.1:${server.port}", + "BOSS_IPC_ADDR" to childAddress, + "BOSS_PROCESS_ID" to "runtime-fixture", + "BOSS_PLUGIN_CLASSPATH" to plugin.absolutePath, + "BOSS_HOST_PID" to ProcessHandle.current().pid().toString(), + IpcEnvironment.PROCESS_TOKEN to processToken, + IpcEnvironment.KERNEL_CERTIFICATE to kernelIdentity.certificateBase64, + IpcEnvironment.SERVER_CERTIFICATE to childIdentity.certificateBase64, + IpcEnvironment.SERVER_PRIVATE_KEY to childIdentity.privateKeyBase64(), + IpcEnvironment.HOST_TOKEN to hostToken, + )) + child = builder.start() + val host = BossIpcClient(childAddress, IpcClientCredentials(childIdentity.certificateBase64, hostToken)) + .also { clients += it } + assertTrue(host.waitForReady(60_000), File(root, "stderr.log").readText().takeLast(8000)) + assertEquals("ready", StateServiceGrpcKt.StateServiceCoroutineStub(host.channel) + .withDeadlineAfter(5, TimeUnit.SECONDS).getState(StateKey.newBuilder().setKey("ready").build()).key) + assertEquals(1, kernel.registeredCount) + withTimeout(10_000) { + while (ui.registrations.get() == 0 || ui.updates.get() == 0) delay(20) + } + val heartbeat = kernel.getLastHeartbeat("runtime-fixture") + withTimeout(10_000) { while (kernel.getLastHeartbeat("runtime-fixture") == heartbeat) delay(20) } + + val wrong = BossIpcClient(childAddress, IpcClientCredentials(childIdentity.certificateBase64, "0".repeat(64))) + .also { clients += it } + val rejected = assertFailsWith { + StateServiceGrpcKt.StateServiceCoroutineStub(wrong.channel).withDeadlineAfter(5, TimeUnit.SECONDS) + .getState(StateKey.newBuilder().setKey("ready").build()) + } + assertEquals(Status.Code.UNAUTHENTICATED, rejected.status.code) + val wrongServer = BossIpcClient( + childAddress, IpcClientCredentials(IpcTlsIdentity.create().certificateBase64, hostToken), + ).also { clients += it } + assertTrue(!wrongServer.waitForReady(2000)) + tokens.revokeIfCurrent("runtime-fixture", processToken) + } finally { + child?.destroyForcibly() + child?.waitFor(10, TimeUnit.SECONDS) + clients.forEach { it.shutdown(0) } + server.stop(0) + root.deleteRecursively() + } + } + + private fun fixturePlugin(root: File): File { + val destination = File(root, "fixture.jar") + JarOutputStream(destination.outputStream()).use { output -> + JarFile(System.getProperty("runtime.fixture.jar")).use { fixture -> + for (entry in fixture.entries()) { + if (!entry.name.endsWith(".class")) continue + output.putNextEntry(JarEntry(entry.name)) + fixture.getInputStream(entry).use { it.copyTo(output) } + output.closeEntry() + } + } + output.putNextEntry(JarEntry("META-INF/boss-plugin/plugin.json")) + output.write("""{"manifestVersion":1,"pluginId":"runtime-fixture","displayName":"Fixture","version":"1.0.0","apiVersion":"1.0.0","mainClass":"ai.rever.boss.plugin.runtime.RuntimeFixturePlugin"}""".toByteArray()) + output.closeEntry() + } + return destination + } +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/runtime/RuntimeFixturePlugin.kt b/src/test/kotlin/ai/rever/boss/plugin/runtime/RuntimeFixturePlugin.kt new file mode 100644 index 0000000..1aaf663 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/runtime/RuntimeFixturePlugin.kt @@ -0,0 +1,21 @@ +package ai.rever.boss.plugin.runtime + +import ai.rever.boss.ipc.proto.UIRegistration +import ai.rever.boss.ipc.proto.WidgetTree +import ai.rever.boss.ipc.services.StateServiceImpl + +/** Loaded reflectively by the actual packaged runtime in a separate JVM. */ +class RuntimeFixturePlugin { + fun registerRemote(context: RemotePluginContext) { + context.addProcessService(StateServiceImpl()) + context.uiService.registerSurface( + UIRegistration.newBuilder() + .setProcessId(context.processId) + .setSurfaceId("runtime-security-fixture") + .setSurfaceType("panel") + .setDisplayName("Runtime security fixture") + .setInitialTree(WidgetTree.getDefaultInstance()) + .build(), + ) + } +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/runtime/stateholders/ProcessRunnerCredentialsTest.kt b/src/test/kotlin/ai/rever/boss/plugin/runtime/stateholders/ProcessRunnerCredentialsTest.kt new file mode 100644 index 0000000..72b9918 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/runtime/stateholders/ProcessRunnerCredentialsTest.kt @@ -0,0 +1,56 @@ +package ai.rever.boss.plugin.runtime.stateholders + +import kotlinx.coroutines.runBlocking +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ProcessRunnerCredentialsTest { + private val java = File(System.getProperty("java.home"), "bin/java") + private val arguments = listOf( + "-cp", File(EnvironmentProbe::class.java.protectionDomain.codeSource.location.toURI()).absolutePath, + EnvironmentProbe::class.java.name, + ) + private val credentials = listOf( + "BOSS_PROCESS_TOKEN", "BOSS_KERNEL_TLS_CERT", "BOSS_IPC_TLS_CERT", "BOSS_IPC_TLS_KEY", "BOSS_HOST_TOKEN", + ) + + private fun environment(override: Boolean): Map = buildMap { + put("BOSS_TEST_NORMAL", "preserved") + if (override) credentials.forEach { name -> + put(name, "synthetic-override") + put(name.lowercase(), "synthetic-case-override") + } + } + + @Test + fun `fixture really inherits credentials before the runner scrubs them`() { + val builder = ProcessBuilder(listOf(java.absolutePath) + arguments) + builder.environment().putAll(environment(false)) + val process = builder.start() + assertEquals("leaked:preserved", process.inputStream.bufferedReader().readText().trim()) + assertTrue(process.waitFor(10, TimeUnit.SECONDS)) + assertEquals(0, process.exitValue()) + } + + @Test + fun `run strips inherited and overridden credentials while retaining ordinary environment`() = runBlocking { + for (override in listOf(false, true)) { + val result = ProcessRunner.run(java, arguments, environment = environment(override)) + assertEquals(0, result.exitCode, result.stderr) + assertEquals("clean:preserved", result.stdout.trim()) + } + } + + @Test + fun `stream strips inherited and overridden credentials while retaining ordinary environment`() = runBlocking { + for (override in listOf(false, true)) { + val lines = mutableListOf() + val result = ProcessRunner.stream(java, arguments, environment = environment(override)) { lines += it } + assertEquals(0, result) + assertEquals(listOf("clean:preserved"), lines) + } + } +} From 44f0d1d4f8bdc5f6aa704bb1b4e2f84583c23d7a Mon Sep 17 00:00:00 2001 From: Raghav Bansal <273537979+Raghav-Bansal-15@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:16:51 +0530 Subject: [PATCH 2/8] test: survive a Windows checkout of the transport contract and the watchdog sleeper --- .gitattributes | 4 ++ .../plugin/runtime/HostDeathWatchdogTest.kt | 68 +++++++++++++++---- .../runtime/RuntimeContractMarkerTest.kt | 34 ++++++++++ 3 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 .gitattributes create mode 100644 src/test/kotlin/ai/rever/boss/plugin/runtime/RuntimeContractMarkerTest.kt diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..afc8ac6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# The IPC transport contract is byte-compared by the host +# (IpcTransport.requireCompatibleRuntime). A CRLF checkout on Windows would +# corrupt it, so line-ending conversion is disabled for it outright. +src/main/resources/META-INF/boss-runtime/security-contract -text diff --git a/src/test/kotlin/ai/rever/boss/plugin/runtime/HostDeathWatchdogTest.kt b/src/test/kotlin/ai/rever/boss/plugin/runtime/HostDeathWatchdogTest.kt index 2b9dc7c..776f746 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/runtime/HostDeathWatchdogTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/runtime/HostDeathWatchdogTest.kt @@ -34,21 +34,42 @@ class HostDeathWatchdogTest { /** * Start a `sleep` that is **not** a child of this JVM, and return a handle to it. * - * The shell backgrounds the sleep and exits, so the sleep is reparented away - exactly the - * relationship a plugin child has to a process it did not spawn. + * The launcher process backgrounds (or starts and abandons) the sleeper and exits, so the + * sleeper's parent is gone - exactly the relationship a plugin child has to a process it did + * not spawn. * - * stderr is deliberately *not* merged into stdout. If it were, a shell warning could land on the - * line the pid is read from; a value that failed to parse would only be annoying, but one that - * parsed to some other live pid would have the test SIGKILL an unrelated process on the machine. - * The command check below is the second guard on that. + * stderr is deliberately *not* merged into stdout. If it were, a launcher warning could land on + * the line the pid is read from; a value that failed to parse would only be annoying, but one + * that parsed to some other live pid would have the test SIGKILL an unrelated process on the + * machine. The command check below is the second guard on that. */ private fun detachedSleeper(): ProcessHandle { - val launcher = ProcessBuilder("sh", "-c", "sleep 60 & echo \$!").start() + // POSIX reparents the sleeper to init, so `sh -c "sleep & echo $!"` leaves it with a dead + // parent. Windows never reparents, but the launcher shell still exits, which is the same + // relationship. It has to be PowerShell and `Start-Process`: there is no `sleep` on a bare + // Windows runner, and MSYS `$!` is a *shell* pid that `ProcessHandle.of` - which speaks + // Windows pids - resolves to some unrelated process or to nothing at all (measured: the + // first three failures of this test on windows-latest were "sleeper already gone"). + // `ping -n 61` sleeps about a minute, has no children, and is trivially killable. + val isWindows = System.getProperty("os.name").lowercase().contains("windows") + val launcher = + if (isWindows) { + ProcessBuilder( + "powershell", + "-NoProfile", + "-NonInteractive", + "-Command", + "Write-Output (Start-Process -FilePath 'ping.exe' " + + "-ArgumentList '-n 61 127.0.0.1' -WindowStyle Hidden -PassThru).Id", + ) + } else { + ProcessBuilder("sh", "-c", "sleep 60 & echo \$!") + }.start() val firstLine = launcher.inputStream .bufferedReader() .readLine() - assertTrue(launcher.waitFor(10, TimeUnit.SECONDS), "launcher shell should exit promptly") + assertTrue(launcher.waitFor(30, TimeUnit.SECONDS), "launcher shell should exit promptly") val pid = requireNotNull(firstLine?.trim()?.toLongOrNull()) { @@ -61,10 +82,11 @@ class HostDeathWatchdogTest { // process this JVM does not own - macOS often leaves command() empty - and an unreadable // command is not evidence of a wrong pid. When it *is* readable, a mismatch means we are // about to SIGKILL something we did not start, so refuse. + val commandToken = if (isWindows) "ping" else "sleep" val command = handle.info().command().orElse("") + handle.info().commandLine().orElse("") if (command.isNotEmpty()) { assertTrue( - command.contains("sleep"), + command.lowercase().contains(commandToken), "resolved pid $pid is $command, not the sleeper - refusing to kill an unrelated process", ) } @@ -238,17 +260,39 @@ class HostDeathWatchdogTest { @Test fun `an OS parent of pid 1 means orphaned, because POSIX reparents to init`() { - // Verified on this platform: an orphan's ppid is 1 and ProcessHandle.of(1) is present and + // Verified on POSIX platforms: an orphan's ppid is 1 and ProcessHandle.of(1) is present and // alive, so parent() returning "empty" is not how being orphaned actually presents. - assertTrue(handleOf(1).isPresent, "pid 1 should be resolvable, which is the whole problem") + val pid1 = handleOf(1) + if (pid1.isEmpty) { + // Windows has no pid 1 to resolve - System Idle Process is 0 and System is 4 - so the + // POSIX reparent case cannot present there at all. Assert the platform fact rather than + // silently passing, and note the orphan path is still exercised on Windows by the + // no-parent and named-dead-host tests. + assertTrue(pid1.isEmpty, "this platform resolves pid 1; run the reparent case below") + return + } - val resolved = resolveHostHandle(declared = null, osParent = { handleOf(1) }, selfPid = 4242) + val resolved = resolveHostHandle(declared = null, osParent = { pid1 }, selfPid = 4242) assertFalse(resolved.isPresent, "init is not a host worth watching - it never exits") } @Test fun `a containerised host that is pid 1 can still name itself`() { + if (handleOf(1).isEmpty) { + // No pid 1 exists here (Windows), so there is nothing to name. The contract under + // test - an explicitly declared pid is honoured ahead of the OS parent - is covered + // by 'a live BOSS_HOST_PID wins over the OS parent', and a declared pid that names + // no live process is the dead-host case: orphaned, never a fallback. + val resolved = resolveHostHandle(declared = "1", osParent = { Optional.empty() }, selfPid = 4242) + + assertFalse( + resolved.isPresent, + "a declared pid naming no live process must mean orphaned, not fall back", + ) + return + } + val resolved = resolveHostHandle(declared = "1", osParent = { Optional.empty() }, selfPid = 4242) assertTrue(resolved.isPresent, "an explicit BOSS_HOST_PID=1 is the escape hatch") diff --git a/src/test/kotlin/ai/rever/boss/plugin/runtime/RuntimeContractMarkerTest.kt b/src/test/kotlin/ai/rever/boss/plugin/runtime/RuntimeContractMarkerTest.kt new file mode 100644 index 0000000..57df587 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/runtime/RuntimeContractMarkerTest.kt @@ -0,0 +1,34 @@ +package ai.rever.boss.plugin.runtime + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The transport contract the host byte-compares at spawn time + * (`IpcTransport.requireCompatibleRuntime`). + * + * The comparison is exact, so anything that rewrites the file defeats the gate. There is no + * `.gitattributes` covering the repository at large - this is the one resource whose bytes are + * semantics, and the whole runtime test suite is otherwise indifferent to line endings. The + * Windows runners used to check this file out with CRLF, the fat jar then carried + * `pinned-tls-v1;subprocess-env-v1\r\n`, and the host refused every runtime that CI had approved: + * the integration test above it reported only "unsupported IPC transport", with nothing pointing + * at the checkout. This pins the resource so a checkout conversion fails here, at the file. + */ +class RuntimeContractMarkerTest { + @Test + fun `the security contract resource is byte-identical to what the host compares`() { + val content = + javaClass + .getResourceAsStream("/META-INF/boss-runtime/security-contract") + ?.readBytes() + ?.toString(Charsets.UTF_8) + + assertEquals( + "pinned-tls-v1;subprocess-env-v1\n", + content, + "the contract marker must be exactly what IpcTransport compares - a rewritten " + + "line ending here makes the host refuse every build this suite approved", + ) + } +} From 83fa824590165c384ecd104c93383c6e518175f2 Mon Sep 17 00:00:00 2001 From: Raghav Bansal <273537979+Raghav-Bansal-15@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:07:40 +0530 Subject: [PATCH 3/8] fix(runtime): align paired validation with the repaired host API --- .github/workflows/build.yml | 2 +- build.gradle.kts | 2 +- gradle.properties | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 54d58e0..e1965bb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v4 with: repository: risa-labs-inc/BossConsole - ref: aa750ce9d2db91bcfad06e01dd1f8cb7b6b12bb8 + ref: d623754ff31a4666b3a86670f63cd5265d01b390 path: host - uses: actions/setup-java@v4 with: diff --git a/build.gradle.kts b/build.gradle.kts index b55f3c7..03ebdd3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -100,7 +100,7 @@ val upstreamJars = listOf( // contract into its own classloader, and a child JVM built against a different revision of it is // exactly the mismatch the IPC compat gate cannot see. val bossPluginApiVersion: String = - providers.gradleProperty("boss.plugin.api.version").orElse("1.0.88").get() + providers.gradleProperty("boss.plugin.api.version").orElse("1.0.90").get() val bossPluginApiJarName = "boss-plugin-api-$bossPluginApiVersion.jar" // Resolution order mirrors the upstream jars: locally built sibling first (both live under diff --git a/gradle.properties b/gradle.properties index 2900bf1..759aa44 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,7 +14,7 @@ ipc.version=1.0.0 # compiles and bundles. `ai.rever.boss.plugin.api.*` does NOT ship in the # BossConsole contract jars — see AGENTS.md. Keep this equal to BossConsole's # `boss-plugin-api` pin in libs.versions.toml. -boss.plugin.api.version=1.0.88 +boss.plugin.api.version=1.0.90 # Runtime release version (bumped per release by CI). runtime.version=1.0.0 From d98614e1d33ce21e2e4f4ac7b3db6317627ceaad Mon Sep 17 00:00:00 2001 From: Raghav Bansal <273537979+Raghav-Bansal-15@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:33:04 +0530 Subject: [PATCH 4/8] ci: pin the final repaired host candidate --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e1965bb..b53d16b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v4 with: repository: risa-labs-inc/BossConsole - ref: d623754ff31a4666b3a86670f63cd5265d01b390 + ref: 06e73dd14fbff4667fc7bcae7640bea1ae6acdcf path: host - uses: actions/setup-java@v4 with: From 68369386c83a02330dedfed55892d1f81c8e8354 Mon Sep 17 00:00:00 2001 From: Raghav Bansal <273537979+Raghav-Bansal-15@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:53:59 +0530 Subject: [PATCH 5/8] build: pair runtime with current authenticated host and API 1.0.93 --- .github/workflows/build.yml | 2 +- build.gradle.kts | 2 +- gradle.properties | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b53d16b..9bc5b32 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v4 with: repository: risa-labs-inc/BossConsole - ref: 06e73dd14fbff4667fc7bcae7640bea1ae6acdcf + ref: 8648bd6e73793c6733df71279b02114a5418d086 path: host - uses: actions/setup-java@v4 with: diff --git a/build.gradle.kts b/build.gradle.kts index 03ebdd3..6a33672 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -100,7 +100,7 @@ val upstreamJars = listOf( // contract into its own classloader, and a child JVM built against a different revision of it is // exactly the mismatch the IPC compat gate cannot see. val bossPluginApiVersion: String = - providers.gradleProperty("boss.plugin.api.version").orElse("1.0.90").get() + providers.gradleProperty("boss.plugin.api.version").orElse("1.0.93").get() val bossPluginApiJarName = "boss-plugin-api-$bossPluginApiVersion.jar" // Resolution order mirrors the upstream jars: locally built sibling first (both live under diff --git a/gradle.properties b/gradle.properties index 759aa44..ea5991e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,7 +14,7 @@ ipc.version=1.0.0 # compiles and bundles. `ai.rever.boss.plugin.api.*` does NOT ship in the # BossConsole contract jars — see AGENTS.md. Keep this equal to BossConsole's # `boss-plugin-api` pin in libs.versions.toml. -boss.plugin.api.version=1.0.90 +boss.plugin.api.version=1.0.93 # Runtime release version (bumped per release by CI). runtime.version=1.0.0 From 717f60bb219c237560d7ece29ef38f1357286dc1 Mon Sep 17 00:00:00 2001 From: Raghav Bansal <273537979+Raghav-Bansal-15@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:27:03 +0530 Subject: [PATCH 6/8] fix: require and validate authenticated IPC 1.1 host --- .github/workflows/build.yml | 2 +- README.md | 2 +- gradle.properties | 2 +- .../resources/META-INF/boss-plugin/plugin.json | 2 +- .../runtime/PackagedRuntimeSecurityTest.kt | 16 ++++++++++++++++ 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9bc5b32..99f94eb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v4 with: repository: risa-labs-inc/BossConsole - ref: 8648bd6e73793c6733df71279b02114a5418d086 + ref: 2cb5c6ac9c356bb60db2a21741bce1fc8b67b155 path: host - uses: actions/setup-java@v4 with: diff --git a/README.md b/README.md index 4826cd9..365f108 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ neither, the runtime logs that it is running in UI-only mode and carries on. ## Requirements - JDK 17 -- `apiVersion` 1.0.0, `minIpcVersion` 1.0.0. The host refuses to spawn a runtime whose IPC +- `apiVersion` 1.0.0, `minIpcVersion` 1.1.0. The host refuses to spawn a runtime whose IPC version it cannot talk to, so the runtime version and the IPC version move independently. - gRPC, protobuf and netty native transports for macOS arm64/x64 and Linux arm64/x64. The **protobuf version must stay at or above BossConsole's pin**, or the child JVM dies during diff --git a/gradle.properties b/gradle.properties index ea5991e..5109e7b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 # Match this to BossConsole's IpcVersion.CURRENT for the BossConsole release # you intend to consume. On a host with a different IPC version the runtime # will fail to spawn (Phase 0 compat gate) — that's the safety net. -ipc.version=1.0.0 +ipc.version=1.1.0 # The plugin API contract (risa-labs-inc/boss-plugin-api releases) this runtime # compiles and bundles. `ai.rever.boss.plugin.api.*` does NOT ship in the diff --git a/src/main/resources/META-INF/boss-plugin/plugin.json b/src/main/resources/META-INF/boss-plugin/plugin.json index 569b55b..e9ce4a7 100644 --- a/src/main/resources/META-INF/boss-plugin/plugin.json +++ b/src/main/resources/META-INF/boss-plugin/plugin.json @@ -7,7 +7,7 @@ "mainClass": "ai.rever.boss.plugin.runtime.PluginProcessMainKt", "type": "service", "description": "Shared runtime for microkernel out-of-process plugin child JVMs", - "minIpcVersion": "1.0.0", + "minIpcVersion": "1.1.0", "systemPlugin": true, "isDynamic": false, "canUnload": false, diff --git a/src/test/kotlin/ai/rever/boss/plugin/runtime/PackagedRuntimeSecurityTest.kt b/src/test/kotlin/ai/rever/boss/plugin/runtime/PackagedRuntimeSecurityTest.kt index b944553..ac7a7c3 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/runtime/PackagedRuntimeSecurityTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/runtime/PackagedRuntimeSecurityTest.kt @@ -2,6 +2,7 @@ package ai.rever.boss.plugin.runtime import ai.rever.boss.ipc.BossIpcClient import ai.rever.boss.ipc.BossIpcServer +import ai.rever.boss.ipc.IpcVersion import ai.rever.boss.ipc.IpcTransport import ai.rever.boss.ipc.auth.IpcCall import ai.rever.boss.ipc.auth.IpcClientCredentials @@ -23,6 +24,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import java.io.File import java.net.ServerSocket import java.nio.file.Files @@ -57,6 +61,18 @@ class PackagedRuntimeSecurityTest { } } + @Test + fun `packaged runtime declares the authenticated host minimum`() { + JarFile(File(System.getProperty("runtime.integration.jar"))).use { jar -> + val text = jar.getInputStream(jar.getJarEntry("META-INF/boss-plugin/plugin.json")) + .bufferedReader().use { it.readText() } + val minimum = Json.parseToJsonElement(text).jsonObject.getValue("minIpcVersion").jsonPrimitive.content + assertEquals("1.1.0", minimum) + assertEquals(IpcVersion.CURRENT, minimum) + assertTrue(IpcVersion.isCompatible(minimum, "1.0.0") is IpcVersion.CompatResult.Incompatible) + } + } + @Test fun `fat jar registers heartbeats serves authenticated calls and sends UI over pinned TLS`() = runBlocking { val root = Files.createTempDirectory("packaged-runtime-").toFile() From 1341900d8a6f05a67f9e60759d62640011e34494 Mon Sep 17 00:00:00 2001 From: Raghav Bansal <273537979+Raghav-Bansal-15@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:37:35 +0530 Subject: [PATCH 7/8] test: pin the final IPC admission repair --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 99f94eb..0a7d277 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v4 with: repository: risa-labs-inc/BossConsole - ref: 2cb5c6ac9c356bb60db2a21741bce1fc8b67b155 + ref: 4f209d77decdbc58b3144ea2eb228b52d8e3abc4 path: host - uses: actions/setup-java@v4 with: From 303eddb8404f1888e380e0ffc373cedd46762f74 Mon Sep 17 00:00:00 2001 From: Raghav Bansal <273537979+Raghav-Bansal-15@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:10:15 +0530 Subject: [PATCH 8/8] Test against protobuf-safe native host configuration --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0a7d277..109c530 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v4 with: repository: risa-labs-inc/BossConsole - ref: 4f209d77decdbc58b3144ea2eb228b52d8e3abc4 + ref: 1ccec3f6e8d29c93ec686f0285b85be5a35306b8 path: host - uses: actions/setup-java@v4 with: