Skip to content
Open
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
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
48 changes: 32 additions & 16 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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: 1ccec3f6e8d29c93ec686f0285b85be5a35306b8
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/
19 changes: 19 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 41 additions & 14 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -96,15 +100,15 @@ 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.93").get()
val bossPluginApiJarName = "boss-plugin-api-$bossPluginApiVersion.jar"

// Resolution order mirrors the upstream jars: locally built sibling first (both live under
// boss_plugins/), then the copy BossConsole already fetched for its own build, then a download.
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() }
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -279,6 +285,8 @@ tasks.register<Jar>("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"
}
Expand Down Expand Up @@ -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<Jar>("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<Jar>("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")
}
}
4 changes: 2 additions & 2 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ 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
# 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.93

# Runtime release version (bumped per release by CI).
runtime.version=1.0.0
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() }
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/META-INF/boss-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/META-INF/boss-runtime/security-contract
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pinned-tls-v1;subprocess-env-v1
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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",
)
}
Expand Down Expand Up @@ -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")
Expand Down
Loading